From 03bc8d549f6e7b30516be8678403fc8c7714685d Mon Sep 17 00:00:00 2001 From: Quentin Peter Date: Wed, 27 Jul 2022 07:11:23 +0200 Subject: [PATCH 1/3] Add .gitattributes --- .gitattributes | 308 ++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 305 insertions(+), 3 deletions(-) diff --git a/.gitattributes b/.gitattributes index b0cde0b8d34..3307e5ebcfd 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,5 +1,307 @@ +# Standard gitattributes config + +# Set the default behavior, in case people don't have core.autocrlf set. +* text eol=lf + +# Explicitly declare text files you want to always be normalized and converted +# to native line endings on checkout. + +*.txt text + +# Source code +*.bash text eol=lf +*.c text +*.cpp text +*.csh text eol=lf +*.fish text eol=lf +*.inc text +*.ipynb text +*.h text +*.ksh text eol=lf +*.ps1 text +*.pxd text diff=python +*.py text diff=python +*.py3 text diff=python +*.pyi text diff=python +*.pyw text diff=python +*.pyx text diff=python +*.qss text +*.r text +*.R text +*.rb text +*.rmd text +*.Rmd text +*.rnw text +*.Rnw text +*.sh text eol=lf +*.zsh text eol=lf + +# Documentation +*.adoc text +*.latex text +*.LaTeX text +*.markdown text +*.md text +*.po text +*.pot text +*.rd text +*.Rd text +*.rst text +*.tex text +*.TeX text +*.tmpl text +*.tpl text + +# Web +*.atom text +*.css text +*.htm text +*.html text +*.js text +*.jsx text +*.json text +*.php text +*.pl text +*.rss text +*.sass text +*.scss text +*.xht text +*.xhtml text + +# Configuration +*.cfg text +*.cnf text +*.conf text +*.config text +*.desktop text +*.inf text +*.ini text +*.plist text +*.toml text +*.xml text +*.yml text +*.yaml text + +# Plain text data +*.cdl text +*.csv text +*.dif text +*.geojson text +*.gml text +*.kml text +*.sql text +*.tab text +*.tsv text +*.wkt text + +# Other text files +*.diff -text +*.patch -text + +# Special files +.*rc text +.checkignore text +.ciocheck text +.ciocopyright text +.editorconfig text +.gitattributes export-ignore +.gitconfig export-ignore +.gitignore export-ignore +.gitmodules export-ignore +.gitkeep export-ignore +*.lektorproject text +.nojekyll text +.project text + +AUTHORS text +CHANGELOG text +CHANGES text +CONTRIBUTING text +INSTALL text +license text +LICENSE text +NEWS text +NOTICES text +readme text +*README* text +RELEASE text +TODO text + +browserslist text +contents.lr text +makefile text +Makefile text +MANIFEST.in text + + +# Declare files that will always have CRLF line endings on checkout. +*.bat text eol=crlf +*.cmd text eol=crlf +*.vbs text eol=crlf +*.vb text eol=crlf + + +# Denote all files that are truly binary and should not be modified. + +# Executable +*.app binary +*.bin binary +*.deb binary +*.dll binary +*.dylib binary +*.elf binary +*.exe binary +*.ko binary +*.lib binary +*.msi binary +*.o binary +*.obj binary +*.pyc binary +*.pyd binary +*.pyo binary +*.rdb binary +*.Rdb binary +*.rdx binary +*.Rdx binary +*.rpm binary +*.so binary +*.sys binary + +# Data +*.cdf binary +*.db binary +*.dta binary +*.feather binary +*.fit binary +*.fits binary +*.fts binary +*.fods binary +*.geotiff binary +*.gpkg binary +*.h4 binary +*.h5 binary +*.hdf binary +*.hdf4 binary +*.hdf5 binary +*.mat binary +*.nc binary +*.npy binary +*.npz binary +*.odb binary +*.ods binary +*.p binary +*.parquet binary +*.pickle binary +*.pkl binary +*.rdata binary +*.Rdata binary +*.RData binary +*.rda binary +*.Rda binary +*.rds binary +*.Rds binary +*.sav binary +*.sqlite binary +*.wkb binary +*.xls binary +*.XLS binary +*.xlsx binary +*.XLSX binary + +# Documents +*.doc binary +*.DOC binary +*.docx binary +*.DOCX binary +*.epub binary +*.fodp binary +*.fodt binary +*.odp binary +*.odt binary +*.pdf binary +*.PDF binary +*.ppt binary +*.PPT binary +*.pptx binary +*.PPTX binary +*.rtf binary +*.RTF binary + +# Graphics +*.ai binary +*.bmp binary +*.eps binary +*.fodg binary +*.gif binary +*.icns binary +*.ico binary +*.jp2 binary +*.jpeg binary +*.jpg binary +*.mo binary +*.pdn binary +*.png binary +*.PNG binary +*.psd binary +*.odg binary +*.svg binary +*.svgz binary +*.tif binary +*.tiff binary +*.webp binary +*.xcf binary + +# Fonts +*.eot binary +*.otc binary +*.otf binary +*.ttc binary +*.ttf binary +*.woff binary +*.woff2 binary + +# Audio/Video +*.aac binary +*.flac binary +*.mka binary +*.mkv binary +*.mp3 binary +*.mp4 binary +*.oga binary +*.ogg binary +*.ogv binary +*.opus binary +*.wav binary +*.webm binary + + +# Archives +*.7z binary +*.bz2 binary +*.dmg binary +*.gz binary +*.lz binary +*.lzma binary +*.pyz binary +*.rar binary +*.sz binary +*.tar binary +*.tbz2 binary +*.tgz binary +*.tlz binary +*.txz binary +*.xz binary +*.zip binary + +# Spyder-related +*.results binary +*.spydata binary + +# Other +*.bak binary +*.lnk binary +*.temp binary +*.tmp binary + # Github helper pieces to make some files not show up in diffs automatically external-deps/**/* linguist-generated=true - -# Get better diffs for Python files -*.py diff=python From bdfe0b59821951584de3f72768693a151ca8350a Mon Sep 17 00:00:00 2001 From: Quentin Peter Date: Wed, 27 Jul 2022 11:55:28 +0200 Subject: [PATCH 2/3] remove CR from .py files --- spyder/app/mainwindow.py | 4098 +++--- spyder/app/restart.py | 602 +- spyder/app/start.py | 540 +- spyder/config/base.py | 1260 +- spyder/config/main.py | 1286 +- spyder/config/manager.py | 1338 +- spyder/config/user.py | 2042 +-- spyder/dependencies.py | 896 +- spyder/otherplugins.py | 256 +- spyder/pil_patch.py | 122 +- spyder/plugins/breakpoints/api.py | 28 +- spyder/plugins/breakpoints/plugin.py | 418 +- .../breakpoints/widgets/main_widget.py | 860 +- spyder/plugins/console/api.py | 36 +- spyder/plugins/console/plugin.py | 538 +- spyder/plugins/console/utils/ansihandler.py | 230 +- spyder/plugins/console/utils/interpreter.py | 670 +- spyder/plugins/console/widgets/__init__.py | 20 +- .../plugins/console/widgets/internalshell.py | 988 +- spyder/plugins/console/widgets/shell.py | 2128 +-- spyder/plugins/editor/extensions/docstring.py | 2068 +-- spyder/plugins/editor/plugin.py | 7168 +++++----- spyder/plugins/editor/utils/findtasks.py | 66 +- spyder/plugins/editor/widgets/base.py | 2314 ++-- spyder/plugins/editor/widgets/codeeditor.py | 11190 ++++++++-------- spyder/plugins/editor/widgets/editor.py | 7222 +++++----- spyder/plugins/explorer/plugin.py | 542 +- spyder/plugins/explorer/widgets/explorer.py | 3876 +++--- spyder/plugins/findinfiles/api.py | 28 +- spyder/plugins/findinfiles/plugin.py | 426 +- .../findinfiles/widgets/results_browser.py | 674 +- spyder/plugins/help/api.py | 30 +- spyder/plugins/help/plugin.py | 750 +- spyder/plugins/help/utils/__init__.py | 40 +- spyder/plugins/history/plugin.py | 274 +- spyder/plugins/io_dcm/plugin.py | 72 +- spyder/plugins/io_hdf5/plugin.py | 164 +- spyder/plugins/ipythonconsole/plugin.py | 1774 +-- .../plugins/ipythonconsole/utils/manager.py | 240 +- .../plugins/ipythonconsole/widgets/client.py | 1872 +-- spyder/plugins/layout/container.py | 880 +- spyder/plugins/layout/layouts.py | 524 +- spyder/plugins/layout/plugin.py | 1658 +-- spyder/plugins/layout/widgets/dialog.py | 790 +- spyder/plugins/maininterpreter/confpage.py | 552 +- spyder/plugins/maininterpreter/plugin.py | 244 +- spyder/plugins/onlinehelp/api.py | 18 +- spyder/plugins/onlinehelp/plugin.py | 190 +- spyder/plugins/onlinehelp/widgets.py | 1026 +- spyder/plugins/outlineexplorer/plugin.py | 216 +- spyder/plugins/outlineexplorer/widgets.py | 1774 +-- spyder/plugins/preferences/api.py | 1784 +-- .../plugins/profiler/widgets/main_widget.py | 2120 +-- spyder/plugins/projects/api.py | 360 +- spyder/plugins/projects/plugin.py | 2044 +-- spyder/plugins/projects/utils/config.py | 222 +- spyder/plugins/projects/widgets/__init__.py | 14 +- .../plugins/projects/widgets/projectdialog.py | 538 +- .../projects/widgets/projectexplorer.py | 698 +- spyder/plugins/pylint/main_widget.py | 1970 +-- spyder/plugins/pylint/plugin.py | 472 +- spyder/plugins/run/confpage.py | 284 +- spyder/plugins/run/plugin.py | 130 +- spyder/plugins/run/widgets.py | 1044 +- spyder/plugins/shortcuts/__init__.py | 24 +- spyder/plugins/shortcuts/api.py | 14 +- spyder/plugins/shortcuts/confpage.py | 190 +- spyder/plugins/shortcuts/plugin.py | 496 +- spyder/plugins/shortcuts/widgets/table.py | 1880 +-- spyder/plugins/statusbar/container.py | 130 +- spyder/plugins/statusbar/plugin.py | 494 +- spyder/plugins/toolbar/container.py | 792 +- spyder/plugins/toolbar/plugin.py | 532 +- spyder/plugins/tours/container.py | 228 +- spyder/plugins/tours/plugin.py | 242 +- spyder/plugins/tours/tours.py | 468 +- spyder/plugins/tours/widgets.py | 2572 ++-- spyder/plugins/variableexplorer/api.py | 28 +- spyder/plugins/variableexplorer/plugin.py | 164 +- .../variableexplorer/widgets/arrayeditor.py | 1878 +-- .../widgets/dataframeeditor.py | 2860 ++-- .../variableexplorer/widgets/importwizard.py | 1284 +- .../variableexplorer/widgets/main_widget.py | 1284 +- .../widgets/namespacebrowser.py | 642 +- .../variableexplorer/widgets/objecteditor.py | 350 +- .../variableexplorer/widgets/texteditor.py | 294 +- spyder/plugins/workingdirectory/confpage.py | 246 +- spyder/plugins/workingdirectory/container.py | 664 +- spyder/plugins/workingdirectory/plugin.py | 524 +- spyder/py3compat.py | 638 +- spyder/requirements.py | 126 +- spyder/utils/bsdsocket.py | 366 +- spyder/utils/conda.py | 328 +- spyder/utils/debug.py | 292 +- spyder/utils/encoding.py | 654 +- spyder/utils/environ.py | 372 +- spyder/utils/external/lockfile.py | 502 +- .../utils/introspection/module_completion.py | 150 +- spyder/utils/introspection/rope_patch.py | 422 +- spyder/utils/misc.py | 584 +- spyder/utils/programs.py | 2138 +-- spyder/utils/qthelpers.py | 1612 +-- spyder/utils/sourcecode.py | 480 +- spyder/utils/syntaxhighlighters.py | 2876 ++-- spyder/utils/system.py | 130 +- spyder/utils/vcs.py | 488 +- spyder/utils/windows.py | 96 +- spyder/widgets/arraybuilder.py | 846 +- spyder/widgets/browser.py | 1232 +- spyder/widgets/collectionseditor.py | 3824 +++--- spyder/widgets/colors.py | 190 +- spyder/widgets/comboboxes.py | 836 +- spyder/widgets/dependencies.py | 312 +- spyder/widgets/findreplace.py | 1320 +- spyder/widgets/mixins.py | 3292 ++--- spyder/widgets/onecolumntree.py | 614 +- spyder/widgets/pathmanager.py | 1012 +- spyder/widgets/simplecodeeditor.py | 1156 +- spyder/widgets/tabs.py | 1012 +- 119 files changed, 62439 insertions(+), 62439 deletions(-) diff --git a/spyder/app/mainwindow.py b/spyder/app/mainwindow.py index 890237af110..bda93a8c2ed 100644 --- a/spyder/app/mainwindow.py +++ b/spyder/app/mainwindow.py @@ -1,2049 +1,2049 @@ -# -*- coding: utf-8 -*- -# -# Copyright © Spyder Project Contributors -# Licensed under the terms of the MIT License -# (see spyder/__init__.py for details) - -""" -Spyder, the Scientific Python Development Environment -===================================================== - -Developed and maintained by the Spyder Project -Contributors - -Copyright © Spyder Project Contributors -Licensed under the terms of the MIT License -(see spyder/__init__.py for details) -""" - -# ============================================================================= -# Stdlib imports -# ============================================================================= -from collections import OrderedDict -from enum import Enum -import errno -import gc -import logging -import os -import os.path as osp -import shutil -import signal -import socket -import sys -import threading -import traceback - -#============================================================================== -# Check requirements before proceeding -#============================================================================== -from spyder import requirements -requirements.check_path() -requirements.check_qt() - -#============================================================================== -# Third-party imports -#============================================================================== -from qtpy.compat import from_qvariant -from qtpy.QtCore import (QCoreApplication, Qt, QTimer, Signal, Slot, - qInstallMessageHandler) -from qtpy.QtGui import QColor, QKeySequence -from qtpy.QtWidgets import (QApplication, QMainWindow, QMenu, QMessageBox, - QShortcut, QStyleFactory) - -# Avoid a "Cannot mix incompatible Qt library" error on Windows platforms -from qtpy import QtSvg # analysis:ignore - -# Avoid a bug in Qt: https://bugreports.qt.io/browse/QTBUG-46720 -from qtpy import QtWebEngineWidgets # analysis:ignore - -from qtawesome.iconic_font import FontError - -#============================================================================== -# Local imports -# NOTE: Move (if possible) import's of widgets and plugins exactly where they -# are needed in MainWindow to speed up perceived startup time (i.e. the time -# from clicking the Spyder icon to showing the splash screen). -#============================================================================== -from spyder import __version__ -from spyder import dependencies -from spyder.app.find_plugins import ( - find_external_plugins, find_internal_plugins) -from spyder.app.utils import ( - create_application, create_splash_screen, create_window, ORIGINAL_SYS_EXIT, - delete_debug_log_files, qt_message_handler, set_links_color, setup_logging, - set_opengl_implementation) -from spyder.api.plugin_registration.registry import PLUGIN_REGISTRY -from spyder.config.base import (_, DEV, get_conf_path, get_debug_level, - get_home_dir, get_module_source_path, - is_pynsist, running_in_mac_app, - running_under_pytest, STDERR) -from spyder.config.gui import is_dark_font_color -from spyder.config.main import OPEN_FILES_PORT -from spyder.config.manager import CONF -from spyder.config.utils import IMPORT_EXT, is_gtk_desktop -from spyder.otherplugins import get_spyderplugins_mods -from spyder.py3compat import configparser as cp, PY3, to_text_string -from spyder.utils import encoding, programs -from spyder.utils.icon_manager import ima -from spyder.utils.misc import (select_port, getcwd_or_home, - get_python_executable) -from spyder.utils.palette import QStylePalette -from spyder.utils.qthelpers import (create_action, add_actions, file_uri, - qapplication, start_file) -from spyder.utils.stylesheet import APP_STYLESHEET - -# Spyder API Imports -from spyder.api.exceptions import SpyderAPIError -from spyder.api.plugins import ( - Plugins, SpyderPlugin, SpyderPluginV2, SpyderDockablePlugin, - SpyderPluginWidget) - -#============================================================================== -# Windows only local imports -#============================================================================== -set_attached_console_visible = None -is_attached_console_visible = None -set_windows_appusermodelid = None -if os.name == 'nt': - from spyder.utils.windows import (set_attached_console_visible, - set_windows_appusermodelid) - -#============================================================================== -# Constants -#============================================================================== -# Module logger -logger = logging.getLogger(__name__) - -#============================================================================== -# Install Qt messaage handler -#============================================================================== -qInstallMessageHandler(qt_message_handler) - -#============================================================================== -# Main Window -#============================================================================== -class MainWindow(QMainWindow): - """Spyder main window""" - DOCKOPTIONS = ( - QMainWindow.AllowTabbedDocks | QMainWindow.AllowNestedDocks | - QMainWindow.AnimatedDocks - ) - SPYDER_PATH = get_conf_path('path') - SPYDER_NOT_ACTIVE_PATH = get_conf_path('not_active_path') - DEFAULT_LAYOUTS = 4 - INITIAL_CWD = getcwd_or_home() - - # Signals - restore_scrollbar_position = Signal() - sig_setup_finished = Signal() - all_actions_defined = Signal() - # type: (OrderedDict, OrderedDict) - sig_pythonpath_changed = Signal(object, object) - sig_open_external_file = Signal(str) - sig_resized = Signal("QResizeEvent") - sig_moved = Signal("QMoveEvent") - sig_layout_setup_ready = Signal(object) # Related to default layouts - - # ---- Plugin handling methods - # ------------------------------------------------------------------------ - def get_plugin(self, plugin_name, error=True): - """ - Return a plugin instance by providing the plugin class. - """ - if plugin_name in PLUGIN_REGISTRY: - return PLUGIN_REGISTRY.get_plugin(plugin_name) - - if error: - raise SpyderAPIError(f'Plugin "{plugin_name}" not found!') - - return None - - def get_dockable_plugins(self): - """Get a list of all dockable plugins.""" - dockable_plugins = [] - for plugin_name in PLUGIN_REGISTRY: - plugin = PLUGIN_REGISTRY.get_plugin(plugin_name) - if isinstance(plugin, (SpyderDockablePlugin, SpyderPluginWidget)): - dockable_plugins.append((plugin_name, plugin)) - return dockable_plugins - - def is_plugin_enabled(self, plugin_name): - """Determine if a given plugin is going to be loaded.""" - return PLUGIN_REGISTRY.is_plugin_enabled(plugin_name) - - def is_plugin_available(self, plugin_name): - """Determine if a given plugin is available.""" - return PLUGIN_REGISTRY.is_plugin_available(plugin_name) - - def show_status_message(self, message, timeout): - """ - Show a status message in Spyder Main Window. - """ - status_bar = self.statusBar() - if status_bar.isVisible(): - status_bar.showMessage(message, timeout) - - def show_plugin_compatibility_message(self, message): - """ - Show a compatibility message. - """ - messageBox = QMessageBox(self) - messageBox.setWindowModality(Qt.NonModal) - messageBox.setAttribute(Qt.WA_DeleteOnClose) - messageBox.setWindowTitle(_('Compatibility Check')) - messageBox.setText(message) - messageBox.setStandardButtons(QMessageBox.Ok) - messageBox.show() - - def register_plugin(self, plugin_name, external=False, omit_conf=False): - """ - Register a plugin in Spyder Main Window. - """ - plugin = PLUGIN_REGISTRY.get_plugin(plugin_name) - - self.set_splash(_("Loading {}...").format(plugin.get_name())) - logger.info("Loading {}...".format(plugin.NAME)) - - # Check plugin compatibility - is_compatible, message = plugin.check_compatibility() - plugin.is_compatible = is_compatible - plugin.get_description() - - if not is_compatible: - self.show_compatibility_message(message) - return - - # Connect Plugin Signals to main window methods - plugin.sig_exception_occurred.connect(self.handle_exception) - plugin.sig_free_memory_requested.connect(self.free_memory) - plugin.sig_quit_requested.connect(self.close) - plugin.sig_redirect_stdio_requested.connect( - self.redirect_internalshell_stdio) - plugin.sig_status_message_requested.connect(self.show_status_message) - - if isinstance(plugin, SpyderDockablePlugin): - plugin.sig_focus_changed.connect(self.plugin_focus_changed) - plugin.sig_switch_to_plugin_requested.connect( - self.switch_to_plugin) - plugin.sig_update_ancestor_requested.connect( - lambda: plugin.set_ancestor(self)) - - # Connect Main window Signals to plugin signals - self.sig_moved.connect(plugin.sig_mainwindow_moved) - self.sig_resized.connect(plugin.sig_mainwindow_resized) - - # Register plugin - plugin._register(omit_conf=omit_conf) - - if isinstance(plugin, SpyderDockablePlugin): - # Add dockwidget - self.add_dockwidget(plugin) - - # Update margins - margin = 0 - if CONF.get('main', 'use_custom_margin'): - margin = CONF.get('main', 'custom_margin') - plugin.update_margins(margin) - - if plugin_name == Plugins.Shortcuts: - for action, context, action_name in self.shortcut_queue: - self.register_shortcut(action, context, action_name) - self.shortcut_queue = [] - - logger.info("Registering shortcuts for {}...".format(plugin.NAME)) - for action_name, action in plugin.get_actions().items(): - context = (getattr(action, 'shortcut_context', plugin.NAME) - or plugin.NAME) - - if getattr(action, 'register_shortcut', True): - if isinstance(action_name, Enum): - action_name = action_name.value - if Plugins.Shortcuts in PLUGIN_REGISTRY: - self.register_shortcut(action, context, action_name) - else: - self.shortcut_queue.append((action, context, action_name)) - - if isinstance(plugin, SpyderDockablePlugin): - try: - context = '_' - name = 'switch to {}'.format(plugin.CONF_SECTION) - shortcut = CONF.get_shortcut(context, name, - plugin_name=plugin.CONF_SECTION) - except (cp.NoSectionError, cp.NoOptionError): - shortcut = None - - sc = QShortcut(QKeySequence(), self, - lambda: self.switch_to_plugin(plugin)) - sc.setContext(Qt.ApplicationShortcut) - plugin._shortcut = sc - - if Plugins.Shortcuts in PLUGIN_REGISTRY: - self.register_shortcut(sc, context, name) - self.register_shortcut( - plugin.toggle_view_action, context, name) - else: - self.shortcut_queue.append((sc, context, name)) - self.shortcut_queue.append( - (plugin.toggle_view_action, context, name)) - - def unregister_plugin(self, plugin): - """ - Unregister a plugin from the Spyder Main Window. - """ - logger.info("Unloading {}...".format(plugin.NAME)) - - # Disconnect all slots - signals = [ - plugin.sig_quit_requested, - plugin.sig_redirect_stdio_requested, - plugin.sig_status_message_requested, - ] - - for sig in signals: - try: - sig.disconnect() - except TypeError: - pass - - # Unregister shortcuts for actions - logger.info("Unregistering shortcuts for {}...".format(plugin.NAME)) - for action_name, action in plugin.get_actions().items(): - context = (getattr(action, 'shortcut_context', plugin.NAME) - or plugin.NAME) - self.shortcuts.unregister_shortcut(action, context, action_name) - - # Unregister switch to shortcut - shortcut = None - try: - context = '_' - name = 'switch to {}'.format(plugin.CONF_SECTION) - shortcut = CONF.get_shortcut(context, name, - plugin_name=plugin.CONF_SECTION) - except Exception: - pass - - if shortcut is not None: - self.shortcuts.unregister_shortcut( - plugin._shortcut, - context, - "Switch to {}".format(plugin.CONF_SECTION), - ) - - # Remove dockwidget - logger.info("Removing {} dockwidget...".format(plugin.NAME)) - self.remove_dockwidget(plugin) - - plugin._unregister() - - def create_plugin_conf_widget(self, plugin): - """ - Create configuration dialog box page widget. - """ - config_dialog = self.prefs_dialog_instance - if plugin.CONF_WIDGET_CLASS is not None and config_dialog is not None: - conf_widget = plugin.CONF_WIDGET_CLASS(plugin, config_dialog) - conf_widget.initialize() - return conf_widget - - @property - def last_plugin(self): - """ - Get last plugin with focus if it is a dockable widget. - - If a non-dockable plugin has the focus this will return by default - the Editor plugin. - """ - # Needed to prevent errors with the old API at - # spyder/plugins/base::_switch_to_plugin - return self.layouts.get_last_plugin() - - def maximize_dockwidget(self, restore=False): - """ - This is needed to prevent errors with the old API at - spyder/plugins/base::_switch_to_plugin. - - See spyder-ide/spyder#15164 - - Parameters - ---------- - restore : bool, optional - If the current dockwidget needs to be restored to its unmaximized - state. The default is False. - """ - self.layouts.maximize_dockwidget(restore=restore) - - def switch_to_plugin(self, plugin, force_focus=None): - """ - Switch to this plugin. - - Notes - ----- - This operation unmaximizes the current plugin (if any), raises - this plugin to view (if it's hidden) and gives it focus (if - possible). - """ - last_plugin = self.last_plugin - try: - # New API - if (last_plugin is not None - and last_plugin.get_widget().is_maximized - and last_plugin is not plugin): - self.layouts.maximize_dockwidget() - except AttributeError: - # Old API - if (last_plugin is not None and self.last_plugin._ismaximized - and last_plugin is not plugin): - self.layouts.maximize_dockwidget() - - try: - # New API - if not plugin.toggle_view_action.isChecked(): - plugin.toggle_view_action.setChecked(True) - plugin.get_widget().is_visible = False - except AttributeError: - # Old API - if not plugin._toggle_view_action.isChecked(): - plugin._toggle_view_action.setChecked(True) - plugin._widget._is_visible = False - - plugin.change_visibility(True, force_focus=force_focus) - - def remove_dockwidget(self, plugin): - """ - Remove a plugin QDockWidget from the main window. - """ - self.removeDockWidget(plugin.dockwidget) - try: - self.widgetlist.remove(plugin) - except ValueError: - pass - - def tabify_plugins(self, first, second): - """Tabify plugin dockwigdets.""" - self.tabifyDockWidget(first.dockwidget, second.dockwidget) - - def tabify_plugin(self, plugin, default=None): - """ - Tabify the plugin using the list of possible TABIFY options. - - Only do this if the dockwidget does not have more dockwidgets - in the same position and if the plugin is using the New API. - """ - def tabify_helper(plugin, next_to_plugins): - for next_to_plugin in next_to_plugins: - try: - self.tabify_plugins(next_to_plugin, plugin) - break - except SpyderAPIError as err: - logger.error(err) - - # If TABIFY not defined use the [default] - tabify = getattr(plugin, 'TABIFY', [default]) - if not isinstance(tabify, list): - next_to_plugins = [tabify] - else: - next_to_plugins = tabify - - # Check if TABIFY is not a list with None as unique value or a default - # list - if tabify in [[None], []]: - return False - - # Get the actual plugins from the names - next_to_plugins = [self.get_plugin(p) for p in next_to_plugins] - - # First time plugin starts - if plugin.get_conf('first_time', True): - if (isinstance(plugin, SpyderDockablePlugin) - and plugin.NAME != Plugins.Console): - logger.info( - "Tabify {} dockwidget for the first time...".format( - plugin.NAME)) - tabify_helper(plugin, next_to_plugins) - - # Show external plugins - if plugin.NAME in PLUGIN_REGISTRY.external_plugins: - plugin.get_widget().toggle_view(True) - - plugin.set_conf('enable', True) - plugin.set_conf('first_time', False) - else: - # This is needed to ensure plugins are placed correctly when - # switching layouts. - logger.info("Tabify {} dockwidget...".format(plugin.NAME)) - # Check if plugin has no other dockwidgets in the same position - if not bool(self.tabifiedDockWidgets(plugin.dockwidget)): - tabify_helper(plugin, next_to_plugins) - - return True - - def handle_exception(self, error_data): - """ - This method will call the handle exception method of the Console - plugin. It is provided as a signal on the Plugin API for convenience, - so that plugin do not need to explicitly call the Console plugin. - - Parameters - ---------- - error_data: dict - The dictionary containing error data. The expected keys are: - >>> error_data= { - "text": str, - "is_traceback": bool, - "repo": str, - "title": str, - "label": str, - "steps": str, - } - - Notes - ----- - The `is_traceback` key indicates if `text` contains plain text or a - Python error traceback. - - The `title` and `repo` keys indicate how the error data should - customize the report dialog and Github error submission. - - The `label` and `steps` keys allow customizing the content of the - error dialog. - """ - console = self.get_plugin(Plugins.Console, error=False) - if console: - console.handle_exception(error_data) - - def __init__(self, splash=None, options=None): - QMainWindow.__init__(self) - qapp = QApplication.instance() - - if running_under_pytest(): - self._proxy_style = None - else: - from spyder.utils.qthelpers import SpyderProxyStyle - # None is needed, see: https://bugreports.qt.io/browse/PYSIDE-922 - self._proxy_style = SpyderProxyStyle(None) - - # Enabling scaling for high dpi - qapp.setAttribute(Qt.AA_UseHighDpiPixmaps) - - # Set Windows app icon to use .ico file - if os.name == "nt": - qapp.setWindowIcon(ima.get_icon("windows_app_icon")) - - # Set default style - self.default_style = str(qapp.style().objectName()) - - # Save command line options for plugins to access them - self._cli_options = options - - logger.info("Start of MainWindow constructor") - - def signal_handler(signum, frame=None): - """Handler for signals.""" - sys.stdout.write('Handling signal: %s\n' % signum) - sys.stdout.flush() - QApplication.quit() - - if os.name == "nt": - try: - import win32api - win32api.SetConsoleCtrlHandler(signal_handler, True) - except ImportError: - pass - else: - signal.signal(signal.SIGTERM, signal_handler) - if not DEV: - # Make spyder quit when presing ctrl+C in the console - # In DEV Ctrl+C doesn't quit, because it helps to - # capture the traceback when spyder freezes - signal.signal(signal.SIGINT, signal_handler) - - # Use a custom Qt stylesheet - if sys.platform == 'darwin': - spy_path = get_module_source_path('spyder') - img_path = osp.join(spy_path, 'images') - mac_style = open(osp.join(spy_path, 'app', 'mac_stylesheet.qss')).read() - mac_style = mac_style.replace('$IMAGE_PATH', img_path) - self.setStyleSheet(mac_style) - - # Shortcut management data - self.shortcut_data = [] - self.shortcut_queue = [] - - # Handle Spyder path - self.path = () - self.not_active_path = () - self.project_path = () - self._path_manager = None - - # New API - self._APPLICATION_TOOLBARS = OrderedDict() - self._STATUS_WIDGETS = OrderedDict() - # Mapping of new plugin identifiers vs old attributtes - # names given for plugins or to prevent collisions with other - # attributes, i.e layout (Qt) vs layout (SpyderPluginV2) - self._INTERNAL_PLUGINS_MAPPING = { - 'console': Plugins.Console, - 'maininterpreter': Plugins.MainInterpreter, - 'outlineexplorer': Plugins.OutlineExplorer, - 'variableexplorer': Plugins.VariableExplorer, - 'ipyconsole': Plugins.IPythonConsole, - 'workingdirectory': Plugins.WorkingDirectory, - 'projects': Plugins.Projects, - 'findinfiles': Plugins.Find, - 'layouts': Plugins.Layout, - } - - self.thirdparty_plugins = [] - - # File switcher - self.switcher = None - - # Preferences - self.prefs_dialog_size = None - self.prefs_dialog_instance = None - - # Actions - self.undo_action = None - self.redo_action = None - self.copy_action = None - self.cut_action = None - self.paste_action = None - self.selectall_action = None - - # Menu bars - self.edit_menu = None - self.edit_menu_actions = [] - self.search_menu = None - self.search_menu_actions = [] - self.source_menu = None - self.source_menu_actions = [] - self.run_menu = None - self.run_menu_actions = [] - self.debug_menu = None - self.debug_menu_actions = [] - - # TODO: Move to corresponding Plugins - self.main_toolbar = None - self.main_toolbar_actions = [] - self.file_toolbar = None - self.file_toolbar_actions = [] - self.run_toolbar = None - self.run_toolbar_actions = [] - self.debug_toolbar = None - self.debug_toolbar_actions = [] - - self.menus = [] - - if running_under_pytest(): - # Show errors in internal console when testing. - CONF.set('main', 'show_internal_errors', False) - - self.CURSORBLINK_OSDEFAULT = QApplication.cursorFlashTime() - - if set_windows_appusermodelid != None: - res = set_windows_appusermodelid() - logger.info("appusermodelid: %s", res) - - # Setting QTimer if running in travis - test_app = os.environ.get('TEST_CI_APP') - if test_app is not None: - app = qapplication() - timer_shutdown_time = 30000 - self.timer_shutdown = QTimer(self) - self.timer_shutdown.timeout.connect(app.quit) - self.timer_shutdown.start(timer_shutdown_time) - - # Showing splash screen - self.splash = splash - if CONF.get('main', 'current_version', '') != __version__: - CONF.set('main', 'current_version', __version__) - # Execute here the actions to be performed only once after - # each update (there is nothing there for now, but it could - # be useful some day...) - - # List of satellite widgets (registered in add_dockwidget): - self.widgetlist = [] - - # Flags used if closing() is called by the exit() shell command - self.already_closed = False - self.is_starting_up = True - self.is_setting_up = True - - self.window_size = None - self.window_position = None - - # To keep track of the last focused widget - self.last_focused_widget = None - self.previous_focused_widget = None - - # Server to open external files on a single instance - # This is needed in order to handle socket creation problems. - # See spyder-ide/spyder#4132. - if os.name == 'nt': - try: - self.open_files_server = socket.socket(socket.AF_INET, - socket.SOCK_STREAM, - socket.IPPROTO_TCP) - except OSError: - self.open_files_server = None - QMessageBox.warning(None, "Spyder", - _("An error occurred while creating a socket needed " - "by Spyder. Please, try to run as an Administrator " - "from cmd.exe the following command and then " - "restart your computer:

netsh winsock reset " - "
").format( - color=QStylePalette.COLOR_BACKGROUND_4)) - else: - self.open_files_server = socket.socket(socket.AF_INET, - socket.SOCK_STREAM, - socket.IPPROTO_TCP) - - # Apply main window settings - self.apply_settings() - - # To set all dockwidgets tabs to be on top (in case we want to do it - # in the future) - # self.setTabPosition(Qt.AllDockWidgetAreas, QTabWidget.North) - - logger.info("End of MainWindow constructor") - - # ---- Window setup - def _update_shortcuts_in_panes_menu(self, show=True): - """ - Display the shortcut for the "Switch to plugin..." on the toggle view - action of the plugins displayed in the Help/Panes menu. - - Notes - ----- - SpyderDockablePlugins provide two actions that function as a single - action. The `Switch to Plugin...` action has an assignable shortcut - via the shortcut preferences. The `Plugin toggle View` in the `View` - application menu, uses a custom `Toggle view action` that displays the - shortcut assigned to the `Switch to Plugin...` action, but is not - triggered by that shortcut. - """ - for plugin_name in PLUGIN_REGISTRY: - plugin = PLUGIN_REGISTRY.get_plugin(plugin_name) - if isinstance(plugin, SpyderDockablePlugin): - try: - # New API - action = plugin.toggle_view_action - except AttributeError: - # Old API - action = plugin._toggle_view_action - - if show: - section = plugin.CONF_SECTION - try: - context = '_' - name = 'switch to {}'.format(section) - shortcut = CONF.get_shortcut( - context, name, plugin_name=section) - except (cp.NoSectionError, cp.NoOptionError): - shortcut = QKeySequence() - else: - shortcut = QKeySequence() - - action.setShortcut(shortcut) - - def setup(self): - """Setup main window.""" - PLUGIN_REGISTRY.sig_plugin_ready.connect( - lambda plugin_name, omit_conf: self.register_plugin( - plugin_name, omit_conf=omit_conf)) - - PLUGIN_REGISTRY.set_main(self) - - # TODO: Remove circular dependency between help and ipython console - # and remove this import. Help plugin should take care of it - from spyder.plugins.help.utils.sphinxify import CSS_PATH, DARK_CSS_PATH - logger.info("*** Start of MainWindow setup ***") - logger.info("Updating PYTHONPATH") - path_dict = self.get_spyder_pythonpath_dict() - self.update_python_path(path_dict) - - logger.info("Applying theme configuration...") - ui_theme = CONF.get('appearance', 'ui_theme') - color_scheme = CONF.get('appearance', 'selected') - - 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) - self.statusBar().setStyleSheet(dark_qss) - css_path = DARK_CSS_PATH - - 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) - self.statusBar().setStyleSheet(light_qss) - css_path = CSS_PATH - - elif ui_theme == 'automatic': - 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) - self.statusBar().setStyleSheet(dark_qss) - css_path = DARK_CSS_PATH - else: - light_qss = str(APP_STYLESHEET) - self.setStyleSheet(light_qss) - self.statusBar().setStyleSheet(light_qss) - css_path = CSS_PATH - - # Set css_path as a configuration to be used by the plugins - CONF.set('appearance', 'css_path', css_path) - - # Status bar - status = self.statusBar() - status.setObjectName("StatusBar") - status.showMessage(_("Welcome to Spyder!"), 5000) - - # Switcher instance - logger.info("Loading switcher...") - self.create_switcher() - - # Load and register internal and external plugins - external_plugins = find_external_plugins() - internal_plugins = find_internal_plugins() - all_plugins = external_plugins.copy() - all_plugins.update(internal_plugins.copy()) - - # Determine 'enable' config for the plugins that have it - enabled_plugins = {} - registry_internal_plugins = {} - registry_external_plugins = {} - for plugin in all_plugins.values(): - plugin_name = plugin.NAME - # Disable panes that use web widgets (currently Help and Online - # Help) if the user asks for it. - # See spyder-ide/spyder#16518 - if self._cli_options.no_web_widgets: - if "help" in plugin_name: - continue - plugin_main_attribute_name = ( - self._INTERNAL_PLUGINS_MAPPING[plugin_name] - if plugin_name in self._INTERNAL_PLUGINS_MAPPING - else plugin_name) - if plugin_name in internal_plugins: - registry_internal_plugins[plugin_name] = ( - plugin_main_attribute_name, plugin) - else: - registry_external_plugins[plugin_name] = ( - plugin_main_attribute_name, plugin) - try: - if CONF.get(plugin_main_attribute_name, "enable"): - enabled_plugins[plugin_name] = plugin - PLUGIN_REGISTRY.set_plugin_enabled(plugin_name) - except (cp.NoOptionError, cp.NoSectionError): - enabled_plugins[plugin_name] = plugin - PLUGIN_REGISTRY.set_plugin_enabled(plugin_name) - - PLUGIN_REGISTRY.set_all_internal_plugins(registry_internal_plugins) - PLUGIN_REGISTRY.set_all_external_plugins(registry_external_plugins) - - # Instantiate internal Spyder 5 plugins - for plugin_name in internal_plugins: - if plugin_name in enabled_plugins: - PluginClass = internal_plugins[plugin_name] - if issubclass(PluginClass, SpyderPluginV2): - PLUGIN_REGISTRY.register_plugin(self, PluginClass, - external=False) - - # Instantiate internal Spyder 4 plugins - for plugin_name in internal_plugins: - if plugin_name in enabled_plugins: - PluginClass = internal_plugins[plugin_name] - if issubclass(PluginClass, SpyderPlugin): - plugin_instance = PLUGIN_REGISTRY.register_plugin( - self, PluginClass, external=False) - self.preferences.register_plugin_preferences( - plugin_instance) - - # Instantiate external Spyder 5 plugins - for plugin_name in external_plugins: - if plugin_name in enabled_plugins: - PluginClass = external_plugins[plugin_name] - try: - plugin_instance = PLUGIN_REGISTRY.register_plugin( - self, PluginClass, external=True) - except Exception as error: - print("%s: %s" % (PluginClass, str(error)), file=STDERR) - traceback.print_exc(file=STDERR) - - self.set_splash(_("Loading old third-party plugins...")) - for mod in get_spyderplugins_mods(): - try: - plugin = PLUGIN_REGISTRY.register_plugin(self, mod, - external=True) - if plugin.check_compatibility()[0]: - if hasattr(plugin, 'CONFIGWIDGET_CLASS'): - self.preferences.register_plugin_preferences(plugin) - - if not hasattr(plugin, 'COMPLETION_PROVIDER_NAME'): - self.thirdparty_plugins.append(plugin) - - # Add to dependencies dialog - module = mod.__name__ - name = module.replace('_', '-') - if plugin.DESCRIPTION: - description = plugin.DESCRIPTION - else: - description = plugin.get_plugin_title() - - dependencies.add(module, name, description, - '', None, kind=dependencies.PLUGIN) - except TypeError: - # Fixes spyder-ide/spyder#13977 - pass - except Exception as error: - print("%s: %s" % (mod, str(error)), file=STDERR) - traceback.print_exc(file=STDERR) - - # Set window title - self.set_window_title() - - # Menus - # TODO: Remove when all menus are migrated to use the Main Menu Plugin - logger.info("Creating Menus...") - from spyder.plugins.mainmenu.api import ( - ApplicationMenus, ToolsMenuSections, FileMenuSections) - mainmenu = self.mainmenu - self.edit_menu = mainmenu.get_application_menu("edit_menu") - self.search_menu = mainmenu.get_application_menu("search_menu") - self.source_menu = mainmenu.get_application_menu("source_menu") - self.source_menu.aboutToShow.connect(self.update_source_menu) - self.run_menu = mainmenu.get_application_menu("run_menu") - self.debug_menu = mainmenu.get_application_menu("debug_menu") - - # Switcher shortcuts - self.file_switcher_action = create_action( - self, - _('File switcher...'), - icon=ima.icon('filelist'), - tip=_('Fast switch between files'), - triggered=self.open_switcher, - context=Qt.ApplicationShortcut, - id_='file_switcher') - self.register_shortcut(self.file_switcher_action, context="_", - name="File switcher") - self.symbol_finder_action = create_action( - self, _('Symbol finder...'), - icon=ima.icon('symbol_find'), - tip=_('Fast symbol search in file'), - triggered=self.open_symbolfinder, - context=Qt.ApplicationShortcut, - id_='symbol_finder') - self.register_shortcut(self.symbol_finder_action, context="_", - name="symbol finder", add_shortcut_to_tip=True) - - def create_edit_action(text, tr_text, icon): - textseq = text.split(' ') - method_name = textseq[0].lower()+"".join(textseq[1:]) - action = create_action(self, tr_text, - icon=icon, - triggered=self.global_callback, - data=method_name, - context=Qt.WidgetShortcut) - self.register_shortcut(action, "Editor", text) - return action - - self.undo_action = create_edit_action('Undo', _('Undo'), - ima.icon('undo')) - self.redo_action = create_edit_action('Redo', _('Redo'), - ima.icon('redo')) - self.copy_action = create_edit_action('Copy', _('Copy'), - ima.icon('editcopy')) - self.cut_action = create_edit_action('Cut', _('Cut'), - ima.icon('editcut')) - self.paste_action = create_edit_action('Paste', _('Paste'), - ima.icon('editpaste')) - self.selectall_action = create_edit_action("Select All", - _("Select All"), - ima.icon('selectall')) - - self.edit_menu_actions += [self.undo_action, self.redo_action, - None, self.cut_action, self.copy_action, - self.paste_action, self.selectall_action, - None] - if self.get_plugin(Plugins.Editor, error=False): - self.edit_menu_actions += self.editor.edit_menu_actions - - switcher_actions = [ - self.file_switcher_action, - self.symbol_finder_action - ] - for switcher_action in switcher_actions: - mainmenu.add_item_to_application_menu( - switcher_action, - menu_id=ApplicationMenus.File, - section=FileMenuSections.Switcher, - before_section=FileMenuSections.Restart) - self.set_splash("") - - # Toolbars - # TODO: Remove after finishing the migration - logger.info("Creating toolbars...") - toolbar = self.toolbar - self.file_toolbar = toolbar.get_application_toolbar("file_toolbar") - self.run_toolbar = toolbar.get_application_toolbar("run_toolbar") - self.debug_toolbar = toolbar.get_application_toolbar("debug_toolbar") - self.main_toolbar = toolbar.get_application_toolbar("main_toolbar") - - # Tools + External Tools (some of this depends on the Application - # plugin) - logger.info("Creating Tools menu...") - - spyder_path_action = create_action( - self, - _("PYTHONPATH manager"), - None, icon=ima.icon('pythonpath'), - triggered=self.show_path_manager, - tip=_("PYTHONPATH manager"), - id_='spyder_path_action') - from spyder.plugins.application.container import ( - ApplicationActions, WinUserEnvDialog) - winenv_action = None - if WinUserEnvDialog: - winenv_action = ApplicationActions.SpyderWindowsEnvVariables - mainmenu.add_item_to_application_menu( - spyder_path_action, - menu_id=ApplicationMenus.Tools, - section=ToolsMenuSections.Tools, - before=winenv_action, - before_section=ToolsMenuSections.External - ) - - # Main toolbar - from spyder.plugins.toolbar.api import ( - ApplicationToolbars, MainToolbarSections) - self.toolbar.add_item_to_application_toolbar( - spyder_path_action, - toolbar_id=ApplicationToolbars.Main, - section=MainToolbarSections.ApplicationSection - ) - - self.set_splash(_("Setting up main window...")) - - # TODO: Migrate to use the MainMenu Plugin instead of list of actions - # Filling out menu/toolbar entries: - add_actions(self.edit_menu, self.edit_menu_actions) - add_actions(self.search_menu, self.search_menu_actions) - add_actions(self.source_menu, self.source_menu_actions) - add_actions(self.run_menu, self.run_menu_actions) - add_actions(self.debug_menu, self.debug_menu_actions) - - # Emitting the signal notifying plugins that main window menu and - # toolbar actions are all defined: - self.all_actions_defined.emit() - - def __getattr__(self, attr): - """ - Redefinition of __getattr__ to enable access to plugins. - - Loaded plugins can be accessed as attributes of the mainwindow - as before, e.g self.console or self.main.console, preserving the - same accessor as before. - """ - # Mapping of new plugin identifiers vs old attributtes - # names given for plugins - try: - if attr in self._INTERNAL_PLUGINS_MAPPING.keys(): - return self.get_plugin( - self._INTERNAL_PLUGINS_MAPPING[attr], error=False) - return self.get_plugin(attr) - except SpyderAPIError: - pass - return super().__getattr__(attr) - - def pre_visible_setup(self): - """ - Actions to be performed before the main window is visible. - - The actions here are related with setting up the main window. - """ - logger.info("Setting up window...") - - for plugin_name in PLUGIN_REGISTRY: - plugin_instance = PLUGIN_REGISTRY.get_plugin(plugin_name) - try: - plugin_instance.before_mainwindow_visible() - except AttributeError: - pass - - # Tabify external plugins which were installed after Spyder was - # installed. - # Note: This is only necessary the first time a plugin is loaded. - # Afterwards, the plugin placement is recorded on the window hexstate, - # which is loaded by the layouts plugin during the next session. - for plugin_name in PLUGIN_REGISTRY.external_plugins: - plugin_instance = PLUGIN_REGISTRY.get_plugin(plugin_name) - if plugin_instance.get_conf('first_time', True): - self.tabify_plugin(plugin_instance, Plugins.Console) - - if self.splash is not None: - self.splash.hide() - - # Menu about to show - for child in self.menuBar().children(): - if isinstance(child, QMenu): - try: - child.aboutToShow.connect(self.update_edit_menu) - child.aboutToShow.connect(self.update_search_menu) - except TypeError: - pass - - # Register custom layouts - for plugin_name in PLUGIN_REGISTRY.external_plugins: - plugin_instance = PLUGIN_REGISTRY.get_plugin(plugin_name) - if hasattr(plugin_instance, 'CUSTOM_LAYOUTS'): - if isinstance(plugin_instance.CUSTOM_LAYOUTS, list): - for custom_layout in plugin_instance.CUSTOM_LAYOUTS: - self.layouts.register_layout( - self, custom_layout) - else: - logger.info( - 'Unable to load custom layouts for {}. ' - 'Expecting a list of layout classes but got {}' - .format(plugin_name, plugin_instance.CUSTOM_LAYOUTS) - ) - - # Needed to ensure dockwidgets/panes layout size distribution - # when a layout state is already present. - # See spyder-ide/spyder#17945 - if self.layouts is not None and CONF.get('main', 'window/state', None): - self.layouts.before_mainwindow_visible() - - logger.info("*** End of MainWindow setup ***") - self.is_starting_up = False - - def post_visible_setup(self): - """ - Actions to be performed only after the main window's `show` method - is triggered. - """ - # Process pending events and hide splash before loading the - # previous session. - QApplication.processEvents() - if self.splash is not None: - self.splash.hide() - - # Call on_mainwindow_visible for all plugins. - for plugin_name in PLUGIN_REGISTRY: - plugin = PLUGIN_REGISTRY.get_plugin(plugin_name) - try: - plugin.on_mainwindow_visible() - QApplication.processEvents() - except AttributeError: - pass - - self.restore_scrollbar_position.emit() - - # Server to maintain just one Spyder instance and open files in it if - # the user tries to start other instances with - # $ spyder foo.py - if ( - CONF.get('main', 'single_instance') and - not self._cli_options.new_instance and - self.open_files_server - ): - t = threading.Thread(target=self.start_open_files_server) - t.daemon = True - t.start() - - # Connect the window to the signal emitted by the previous server - # when it gets a client connected to it - self.sig_open_external_file.connect(self.open_external_file) - - # Update plugins toggle actions to show the "Switch to" plugin shortcut - self._update_shortcuts_in_panes_menu() - - # Reopen last session if no project is active - # NOTE: This needs to be after the calls to on_mainwindow_visible - self.reopen_last_session() - - # Raise the menuBar to the top of the main window widget's stack - # Fixes spyder-ide/spyder#3887. - self.menuBar().raise_() - - # To avoid regressions. We shouldn't have loaded the modules - # below at this point. - if DEV is not None: - assert 'pandas' not in sys.modules - assert 'matplotlib' not in sys.modules - - # Restore undocked plugins - self.restore_undocked_plugins() - - # Notify that the setup of the mainwindow was finished - self.is_setting_up = False - self.sig_setup_finished.emit() - - def reopen_last_session(self): - """ - Reopen last session if no project is active. - - This can't be moved to on_mainwindow_visible in the editor because we - need to let the same method on Projects run first. - """ - projects = self.get_plugin(Plugins.Projects, error=False) - editor = self.get_plugin(Plugins.Editor, error=False) - reopen_last_session = False - - if projects: - if projects.get_active_project() is None: - reopen_last_session = True - else: - reopen_last_session = True - - if editor and reopen_last_session: - editor.setup_open_files(close_previous_files=False) - - def restore_undocked_plugins(self): - """Restore plugins that were undocked in the previous session.""" - logger.info("Restoring undocked plugins from the previous session") - - for plugin_name in PLUGIN_REGISTRY: - plugin = PLUGIN_REGISTRY.get_plugin(plugin_name) - if isinstance(plugin, SpyderDockablePlugin): - if plugin.get_conf('undocked_on_window_close', default=False): - plugin.get_widget().create_window() - elif isinstance(plugin, SpyderPluginWidget): - if plugin.get_option('undocked_on_window_close', - default=False): - plugin._create_window() - - def set_window_title(self): - """Set window title.""" - if DEV is not None: - title = u"Spyder %s (Python %s.%s)" % (__version__, - sys.version_info[0], - sys.version_info[1]) - elif running_in_mac_app() or is_pynsist(): - title = "Spyder" - else: - title = u"Spyder (Python %s.%s)" % (sys.version_info[0], - sys.version_info[1]) - - if get_debug_level(): - title += u" [DEBUG MODE %d]" % get_debug_level() - - window_title = self._cli_options.window_title - if window_title is not None: - title += u' -- ' + to_text_string(window_title) - - # TODO: Remove self.projects reference once there's an API for setting - # window title. - projects = self.get_plugin(Plugins.Projects, error=False) - if projects: - path = projects.get_active_project_path() - if path: - path = path.replace(get_home_dir(), u'~') - title = u'{0} - {1}'.format(path, title) - - self.base_title = title - self.setWindowTitle(self.base_title) - - # TODO: To be removed after all actions are moved to their corresponding - # plugins - def register_shortcut(self, qaction_or_qshortcut, context, name, - add_shortcut_to_tip=True, plugin_name=None): - shortcuts = self.get_plugin(Plugins.Shortcuts, error=False) - if shortcuts: - shortcuts.register_shortcut( - qaction_or_qshortcut, - context, - name, - add_shortcut_to_tip=add_shortcut_to_tip, - plugin_name=plugin_name, - ) - - # --- Other - def update_source_menu(self): - """Update source menu options that vary dynamically.""" - # This is necessary to avoid an error at startup. - # Fixes spyder-ide/spyder#14901 - try: - editor = self.get_plugin(Plugins.Editor, error=False) - if editor: - editor.refresh_formatter_name() - except AttributeError: - pass - - def free_memory(self): - """Free memory after event.""" - gc.collect() - - def plugin_focus_changed(self): - """Focus has changed from one plugin to another""" - self.update_edit_menu() - self.update_search_menu() - - def show_shortcuts(self, menu): - """Show action shortcuts in menu.""" - menu_actions = menu.actions() - for action in menu_actions: - if getattr(action, '_shown_shortcut', False): - # This is a SpyderAction - if action._shown_shortcut is not None: - action.setShortcut(action._shown_shortcut) - elif action.menu() is not None: - # This is submenu, so we need to call this again - self.show_shortcuts(action.menu()) - else: - # We don't need to do anything for other elements - continue - - def hide_shortcuts(self, menu): - """Hide action shortcuts in menu.""" - menu_actions = menu.actions() - for action in menu_actions: - if getattr(action, '_shown_shortcut', False): - # This is a SpyderAction - if action._shown_shortcut is not None: - action.setShortcut(QKeySequence()) - elif action.menu() is not None: - # This is submenu, so we need to call this again - self.hide_shortcuts(action.menu()) - else: - # We don't need to do anything for other elements - continue - - def hide_options_menus(self): - """Hide options menu when menubar is pressed in macOS.""" - for plugin in self.widgetlist + self.thirdparty_plugins: - if plugin.CONF_SECTION == 'editor': - editorstack = self.editor.get_current_editorstack() - editorstack.menu.hide() - else: - try: - # New API - plugin.options_menu.hide() - except AttributeError: - # Old API - plugin._options_menu.hide() - - def get_focus_widget_properties(self): - """Get properties of focus widget - Returns tuple (widget, properties) where properties is a tuple of - booleans: (is_console, not_readonly, readwrite_editor)""" - from spyder.plugins.editor.widgets.base import TextEditBaseWidget - from spyder.plugins.ipythonconsole.widgets import ControlWidget - widget = QApplication.focusWidget() - - textedit_properties = None - if isinstance(widget, (TextEditBaseWidget, ControlWidget)): - console = isinstance(widget, ControlWidget) - not_readonly = not widget.isReadOnly() - readwrite_editor = not_readonly and not console - textedit_properties = (console, not_readonly, readwrite_editor) - return widget, textedit_properties - - def update_edit_menu(self): - """Update edit menu""" - widget, textedit_properties = self.get_focus_widget_properties() - if textedit_properties is None: # widget is not an editor/console - return - # !!! Below this line, widget is expected to be a QPlainTextEdit - # instance - console, not_readonly, readwrite_editor = textedit_properties - - if hasattr(self, 'editor'): - # Editor has focus and there is no file opened in it - if (not console and not_readonly and self.editor - and not self.editor.is_file_opened()): - return - - # Disabling all actions to begin with - for child in self.edit_menu.actions(): - child.setEnabled(False) - - self.selectall_action.setEnabled(True) - - # Undo, redo - self.undo_action.setEnabled( readwrite_editor \ - and widget.document().isUndoAvailable() ) - self.redo_action.setEnabled( readwrite_editor \ - and widget.document().isRedoAvailable() ) - - # Copy, cut, paste, delete - has_selection = widget.has_selected_text() - self.copy_action.setEnabled(has_selection) - self.cut_action.setEnabled(has_selection and not_readonly) - self.paste_action.setEnabled(not_readonly) - - # Comment, uncomment, indent, unindent... - if not console and not_readonly: - # This is the editor and current file is writable - if self.get_plugin(Plugins.Editor, error=False): - for action in self.editor.edit_menu_actions: - action.setEnabled(True) - - def update_search_menu(self): - """Update search menu""" - # Disabling all actions except the last one - # (which is Find in files) to begin with - for child in self.search_menu.actions()[:-1]: - child.setEnabled(False) - - widget, textedit_properties = self.get_focus_widget_properties() - if textedit_properties is None: # widget is not an editor/console - return - - # !!! Below this line, widget is expected to be a QPlainTextEdit - # instance - console, not_readonly, readwrite_editor = textedit_properties - - # Find actions only trigger an effect in the Editor - if not console: - for action in self.search_menu.actions(): - try: - action.setEnabled(True) - except RuntimeError: - pass - - # Disable the replace action for read-only files - if len(self.search_menu_actions) > 3: - self.search_menu_actions[3].setEnabled(readwrite_editor) - - def createPopupMenu(self): - return self.application.get_application_context_menu(parent=self) - - def set_splash(self, message): - """Set splash message""" - if self.splash is None: - return - if message: - logger.info(message) - self.splash.show() - self.splash.showMessage(message, - int(Qt.AlignBottom | Qt.AlignCenter | - Qt.AlignAbsolute), - QColor(Qt.white)) - QApplication.processEvents() - - def closeEvent(self, event): - """closeEvent reimplementation""" - if self.closing(True): - event.accept() - else: - event.ignore() - - def resizeEvent(self, event): - """Reimplement Qt method""" - if not self.isMaximized() and not self.layouts.get_fullscreen_flag(): - self.window_size = self.size() - QMainWindow.resizeEvent(self, event) - - # To be used by the tour to be able to resize - self.sig_resized.emit(event) - - def moveEvent(self, event): - """Reimplement Qt method""" - if hasattr(self, 'layouts'): - if not self.isMaximized() and not self.layouts.get_fullscreen_flag(): - self.window_position = self.pos() - QMainWindow.moveEvent(self, event) - # To be used by the tour to be able to move - self.sig_moved.emit(event) - - def hideEvent(self, event): - """Reimplement Qt method""" - try: - for plugin in (self.widgetlist + self.thirdparty_plugins): - # TODO: Remove old API - try: - # New API - if plugin.get_widget().isAncestorOf( - self.last_focused_widget): - plugin.change_visibility(True) - except AttributeError: - # Old API - if plugin.isAncestorOf(self.last_focused_widget): - plugin._visibility_changed(True) - - QMainWindow.hideEvent(self, event) - except RuntimeError: - QMainWindow.hideEvent(self, event) - - def change_last_focused_widget(self, old, now): - """To keep track of to the last focused widget""" - if (now is None and QApplication.activeWindow() is not None): - QApplication.activeWindow().setFocus() - self.last_focused_widget = QApplication.focusWidget() - elif now is not None: - self.last_focused_widget = now - - self.previous_focused_widget = old - - def closing(self, cancelable=False, close_immediately=False): - """Exit tasks""" - if self.already_closed or self.is_starting_up: - return True - - self.plugin_registry = PLUGIN_REGISTRY - - if cancelable and CONF.get('main', 'prompt_on_exit'): - reply = QMessageBox.critical(self, 'Spyder', - 'Do you really want to exit?', - QMessageBox.Yes, QMessageBox.No) - if reply == QMessageBox.No: - return False - - can_close = self.plugin_registry.delete_all_plugins( - excluding={Plugins.Layout}, - close_immediately=close_immediately) - - if not can_close and not close_immediately: - return False - - # Save window settings *after* closing all plugin windows, in order - # to show them in their previous locations in the next session. - # Fixes spyder-ide/spyder#12139 - prefix = 'window' + '/' - if self.layouts is not None: - self.layouts.save_current_window_settings(prefix) - try: - layouts_container = self.layouts.get_container() - if layouts_container: - layouts_container.close() - layouts_container.deleteLater() - self.layouts.deleteLater() - self.plugin_registry.delete_plugin( - Plugins.Layout, teardown=False) - except RuntimeError: - pass - - self.already_closed = True - - if CONF.get('main', 'single_instance') and self.open_files_server: - self.open_files_server.close() - - QApplication.processEvents() - - return True - - def add_dockwidget(self, plugin): - """ - Add a plugin QDockWidget to the main window. - """ - try: - # New API - if plugin.is_compatible: - dockwidget, location = plugin.create_dockwidget(self) - self.addDockWidget(location, dockwidget) - self.widgetlist.append(plugin) - except AttributeError: - # Old API - if plugin._is_compatible: - dockwidget, location = plugin._create_dockwidget() - self.addDockWidget(location, dockwidget) - self.widgetlist.append(plugin) - - def global_callback(self): - """Global callback""" - widget = QApplication.focusWidget() - action = self.sender() - callback = from_qvariant(action.data(), to_text_string) - from spyder.plugins.editor.widgets.base import TextEditBaseWidget - from spyder.plugins.ipythonconsole.widgets import ControlWidget - - if isinstance(widget, (TextEditBaseWidget, ControlWidget)): - getattr(widget, callback)() - else: - return - - def redirect_internalshell_stdio(self, state): - console = self.get_plugin(Plugins.Console, error=False) - if console: - if state: - console.redirect_stds() - else: - console.restore_stds() - - def open_external_console(self, fname, wdir, args, interact, debug, python, - python_args, systerm, post_mortem=False): - """Open external console""" - if systerm: - # Running script in an external system terminal - try: - if CONF.get('main_interpreter', 'default'): - executable = get_python_executable() - else: - executable = CONF.get('main_interpreter', 'executable') - pypath = CONF.get('main', 'spyder_pythonpath', None) - programs.run_python_script_in_terminal( - fname, wdir, args, interact, debug, python_args, - executable, pypath) - except NotImplementedError: - QMessageBox.critical(self, _("Run"), - _("Running an external system terminal " - "is not supported on platform %s." - ) % os.name) - - def open_file(self, fname, external=False): - """ - Open filename with the appropriate application - Redirect to the right widget (txt -> editor, spydata -> workspace, ...) - or open file outside Spyder (if extension is not supported) - """ - fname = to_text_string(fname) - ext = osp.splitext(fname)[1] - editor = self.get_plugin(Plugins.Editor, error=False) - variableexplorer = self.get_plugin( - Plugins.VariableExplorer, error=False) - - if encoding.is_text_file(fname): - if editor: - editor.load(fname) - elif variableexplorer is not None and ext in IMPORT_EXT: - variableexplorer.get_widget().import_data(fname) - elif not external: - fname = file_uri(fname) - start_file(fname) - - def get_initial_working_directory(self): - """Return the initial working directory.""" - return self.INITIAL_CWD - - def open_external_file(self, fname): - """ - Open external files that can be handled either by the Editor or the - variable explorer inside Spyder. - """ - # Check that file exists - fname = encoding.to_unicode_from_fs(fname) - initial_cwd = self.get_initial_working_directory() - if osp.exists(osp.join(initial_cwd, fname)): - fpath = osp.join(initial_cwd, fname) - elif osp.exists(fname): - fpath = fname - else: - return - - # Don't open script that starts Spyder at startup. - # Fixes issue spyder-ide/spyder#14483 - if sys.platform == 'darwin' and 'bin/spyder' in fname: - return - - if osp.isfile(fpath): - self.open_file(fpath, external=True) - elif osp.isdir(fpath): - QMessageBox.warning( - self, _("Error"), - _('To open {fpath} as a project with Spyder, ' - 'please use spyder -p "{fname}".') - .format(fpath=osp.normpath(fpath), fname=fname) - ) - - # --- Path Manager - # ------------------------------------------------------------------------ - def load_python_path(self): - """Load path stored in Spyder configuration folder.""" - if osp.isfile(self.SPYDER_PATH): - with open(self.SPYDER_PATH, 'r', encoding='utf-8') as f: - path = f.read().splitlines() - self.path = tuple(name for name in path if osp.isdir(name)) - - if osp.isfile(self.SPYDER_NOT_ACTIVE_PATH): - with open(self.SPYDER_NOT_ACTIVE_PATH, 'r', - encoding='utf-8') as f: - not_active_path = f.read().splitlines() - self.not_active_path = tuple(name for name in not_active_path - if osp.isdir(name)) - - def save_python_path(self, new_path_dict): - """ - Save path in Spyder configuration folder. - - `new_path_dict` is an OrderedDict that has the new paths as keys and - the state as values. The state is `True` for active and `False` for - inactive. - """ - path = [p for p in new_path_dict] - not_active_path = [p for p in new_path_dict if not new_path_dict[p]] - try: - encoding.writelines(path, self.SPYDER_PATH) - encoding.writelines(not_active_path, self.SPYDER_NOT_ACTIVE_PATH) - except EnvironmentError as e: - logger.error(str(e)) - CONF.set('main', 'spyder_pythonpath', self.get_spyder_pythonpath()) - - def get_spyder_pythonpath_dict(self): - """ - Return Spyder PYTHONPATH. - - The returned ordered dictionary has the paths as keys and the state - as values. The state is `True` for active and `False` for inactive. - - Example: - OrderedDict([('/some/path, True), ('/some/other/path, False)]) - """ - self.load_python_path() - - path_dict = OrderedDict() - for path in self.path: - path_dict[path] = path not in self.not_active_path - - for path in self.project_path: - path_dict[path] = True - - return path_dict - - def get_spyder_pythonpath(self): - """ - Return Spyder PYTHONPATH. - """ - path_dict = self.get_spyder_pythonpath_dict() - path = [k for k, v in path_dict.items() if v] - return path - - def update_python_path(self, new_path_dict): - """Update python path on Spyder interpreter and kernels.""" - # Load previous path - path_dict = self.get_spyder_pythonpath_dict() - - # Save path - if path_dict != new_path_dict: - # It doesn't include the project_path - self.save_python_path(new_path_dict) - - # Load new path - new_path_dict_p = self.get_spyder_pythonpath_dict() # Includes project - - # Any plugin that needs to do some work based on this signal should - # connect to it on plugin registration - self.sig_pythonpath_changed.emit(path_dict, new_path_dict_p) - - @Slot() - def show_path_manager(self): - """Show path manager dialog.""" - def _dialog_finished(result_code): - """Restore path manager dialog instance variable.""" - self._path_manager = None - - if self._path_manager is None: - from spyder.widgets.pathmanager import PathManager - projects = self.get_plugin(Plugins.Projects, error=False) - read_only_path = () - if projects: - read_only_path = tuple(projects.get_pythonpath()) - - dialog = PathManager(self, self.path, read_only_path, - self.not_active_path, sync=True) - self._path_manager = dialog - dialog.sig_path_changed.connect(self.update_python_path) - dialog.redirect_stdio.connect(self.redirect_internalshell_stdio) - dialog.finished.connect(_dialog_finished) - dialog.show() - else: - self._path_manager.show() - self._path_manager.activateWindow() - self._path_manager.raise_() - self._path_manager.setFocus() - - def pythonpath_changed(self): - """Project's PYTHONPATH contribution has changed.""" - projects = self.get_plugin(Plugins.Projects, error=False) - - self.project_path = () - if projects: - self.project_path = tuple(projects.get_pythonpath()) - path_dict = self.get_spyder_pythonpath_dict() - self.update_python_path(path_dict) - - #---- Preferences - def apply_settings(self): - """Apply main window settings.""" - qapp = QApplication.instance() - - # Set 'gtk+' as the default theme in Gtk-based desktops - # Fixes spyder-ide/spyder#2036. - if is_gtk_desktop() and ('GTK+' in QStyleFactory.keys()): - try: - qapp.setStyle('gtk+') - except: - pass - - default = self.DOCKOPTIONS - if CONF.get('main', 'vertical_tabs'): - default = default|QMainWindow.VerticalTabs - self.setDockOptions(default) - - self.apply_panes_settings() - - if CONF.get('main', 'use_custom_cursor_blinking'): - qapp.setCursorFlashTime( - CONF.get('main', 'custom_cursor_blinking')) - else: - qapp.setCursorFlashTime(self.CURSORBLINK_OSDEFAULT) - - def apply_panes_settings(self): - """Update dockwidgets features settings.""" - for plugin in (self.widgetlist + self.thirdparty_plugins): - features = plugin.dockwidget.FEATURES - - plugin.dockwidget.setFeatures(features) - - try: - # New API - margin = 0 - if CONF.get('main', 'use_custom_margin'): - margin = CONF.get('main', 'custom_margin') - plugin.update_margins(margin) - except AttributeError: - # Old API - plugin._update_margins() - - @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 - - # ---- Open files server - def start_open_files_server(self): - self.open_files_server.setsockopt(socket.SOL_SOCKET, - socket.SO_REUSEADDR, 1) - port = select_port(default_port=OPEN_FILES_PORT) - CONF.set('main', 'open_files_port', port) - - # This is necessary in case it's not possible to bind a port for the - # server in the system. - # Fixes spyder-ide/spyder#18262 - try: - self.open_files_server.bind(('127.0.0.1', port)) - except OSError: - self.open_files_server = None - return - - # Number of petitions the server can queue - self.open_files_server.listen(20) - - while 1: # 1 is faster than True - try: - req, dummy = self.open_files_server.accept() - except socket.error as e: - # See spyder-ide/spyder#1275 for details on why errno EINTR is - # silently ignored here. - eintr = errno.WSAEINTR if os.name == 'nt' else errno.EINTR - # To avoid a traceback after closing on Windows - if e.args[0] == eintr: - continue - # handle a connection abort on close error - enotsock = (errno.WSAENOTSOCK if os.name == 'nt' - else errno.ENOTSOCK) - if e.args[0] in [errno.ECONNABORTED, enotsock]: - return - if self.already_closed: - return - raise - fname = req.recv(1024) - fname = fname.decode('utf-8') - self.sig_open_external_file.emit(fname) - req.sendall(b' ') - - # ---- Quit and restart, and reset spyder defaults - @Slot() - def reset_spyder(self): - """ - Quit and reset Spyder and then Restart application. - """ - answer = QMessageBox.warning(self, _("Warning"), - _("Spyder will restart and reset to default settings:

" - "Do you want to continue?"), - QMessageBox.Yes | QMessageBox.No) - if answer == QMessageBox.Yes: - self.restart(reset=True) - - @Slot() - def restart(self, reset=False, close_immediately=False): - """Wrapper to handle plugins request to restart Spyder.""" - self.application.restart( - reset=reset, close_immediately=close_immediately) - - # ---- Global Switcher - def open_switcher(self, symbol=False): - """Open switcher dialog box.""" - if self.switcher is not None and self.switcher.isVisible(): - self.switcher.clear() - self.switcher.hide() - return - if symbol: - self.switcher.set_search_text('@') - else: - self.switcher.set_search_text('') - self.switcher.setup() - self.switcher.show() - - # Note: The +6 pixel on the top makes it look better - # FIXME: Why is this using the toolbars menu? A: To not be on top of - # the toolbars. - # Probably toolbars should be taken into account for this 'delta' only - # when are visible - delta_top = (self.toolbar.toolbars_menu.geometry().height() + - self.menuBar().geometry().height() + 6) - - self.switcher.set_position(delta_top) - - def open_symbolfinder(self): - """Open symbol list management dialog box.""" - self.open_switcher(symbol=True) - - def create_switcher(self): - """Create switcher dialog instance.""" - if self.switcher is None: - from spyder.widgets.switcher import Switcher - self.switcher = Switcher(self) - - return self.switcher - - # --- For OpenGL - def _test_setting_opengl(self, option): - """Get the current OpenGL implementation in use""" - if option == 'software': - return QCoreApplication.testAttribute(Qt.AA_UseSoftwareOpenGL) - elif option == 'desktop': - return QCoreApplication.testAttribute(Qt.AA_UseDesktopOpenGL) - elif option == 'gles': - return QCoreApplication.testAttribute(Qt.AA_UseOpenGLES) - - -#============================================================================== -# Main -#============================================================================== -def main(options, args): - """Main function""" - # **** For Pytest **** - if running_under_pytest(): - if CONF.get('main', 'opengl') != 'automatic': - option = CONF.get('main', 'opengl') - set_opengl_implementation(option) - - app = create_application() - window = create_window(MainWindow, app, None, options, None) - return window - - # **** Handle hide_console option **** - if options.show_console: - print("(Deprecated) --show console does nothing, now the default " - " behavior is to show the console, use --hide-console if you " - "want to hide it") - - if set_attached_console_visible is not None: - set_attached_console_visible(not options.hide_console - or options.reset_config_files - or options.reset_to_defaults - or options.optimize - or bool(get_debug_level())) - - # **** Set OpenGL implementation to use **** - # This attribute must be set before creating the application. - # See spyder-ide/spyder#11227 - if options.opengl_implementation: - option = options.opengl_implementation - set_opengl_implementation(option) - else: - if CONF.get('main', 'opengl') != 'automatic': - option = CONF.get('main', 'opengl') - set_opengl_implementation(option) - - # **** Set high DPI scaling **** - # This attribute must be set before creating the application. - if hasattr(Qt, 'AA_EnableHighDpiScaling'): - QCoreApplication.setAttribute(Qt.AA_EnableHighDpiScaling, - CONF.get('main', 'high_dpi_scaling')) - - # **** Set debugging info **** - if get_debug_level() > 0: - delete_debug_log_files() - setup_logging(options) - - # **** Create the application **** - app = create_application() - - # **** Create splash screen **** - splash = create_splash_screen() - if splash is not None: - splash.show() - splash.showMessage( - _("Initializing..."), - int(Qt.AlignBottom | Qt.AlignCenter | Qt.AlignAbsolute), - QColor(Qt.white) - ) - QApplication.processEvents() - - if options.reset_to_defaults: - # Reset Spyder settings to defaults - CONF.reset_to_defaults() - return - elif options.optimize: - # Optimize the whole Spyder's source code directory - import spyder - programs.run_python_script(module="compileall", - args=[spyder.__path__[0]], p_args=['-O']) - return - - # **** Read faulthandler log file **** - faulthandler_file = get_conf_path('faulthandler.log') - previous_crash = '' - if osp.exists(faulthandler_file): - with open(faulthandler_file, 'r') as f: - previous_crash = f.read() - - # Remove file to not pick it up for next time. - try: - dst = get_conf_path('faulthandler.log.old') - shutil.move(faulthandler_file, dst) - except Exception: - pass - CONF.set('main', 'previous_crash', previous_crash) - - # **** Set color for links **** - set_links_color(app) - - # **** Create main window **** - mainwindow = None - try: - if PY3 and options.report_segfault: - import faulthandler - with open(faulthandler_file, 'w') as f: - faulthandler.enable(file=f) - mainwindow = create_window( - MainWindow, app, splash, options, args - ) - else: - mainwindow = create_window(MainWindow, app, splash, options, args) - except FontError: - QMessageBox.information(None, "Spyder", - "Spyder was unable to load the Spyder 3 " - "icon theme. That's why it's going to fallback to the " - "theme used in Spyder 2.

" - "For that, please close this window and start Spyder again.") - CONF.set('appearance', 'icon_theme', 'spyder 2') - if mainwindow is None: - # An exception occurred - if splash is not None: - splash.hide() - return - - ORIGINAL_SYS_EXIT() - - -if __name__ == "__main__": - main() +# -*- coding: utf-8 -*- +# +# Copyright © Spyder Project Contributors +# Licensed under the terms of the MIT License +# (see spyder/__init__.py for details) + +""" +Spyder, the Scientific Python Development Environment +===================================================== + +Developed and maintained by the Spyder Project +Contributors + +Copyright © Spyder Project Contributors +Licensed under the terms of the MIT License +(see spyder/__init__.py for details) +""" + +# ============================================================================= +# Stdlib imports +# ============================================================================= +from collections import OrderedDict +from enum import Enum +import errno +import gc +import logging +import os +import os.path as osp +import shutil +import signal +import socket +import sys +import threading +import traceback + +#============================================================================== +# Check requirements before proceeding +#============================================================================== +from spyder import requirements +requirements.check_path() +requirements.check_qt() + +#============================================================================== +# Third-party imports +#============================================================================== +from qtpy.compat import from_qvariant +from qtpy.QtCore import (QCoreApplication, Qt, QTimer, Signal, Slot, + qInstallMessageHandler) +from qtpy.QtGui import QColor, QKeySequence +from qtpy.QtWidgets import (QApplication, QMainWindow, QMenu, QMessageBox, + QShortcut, QStyleFactory) + +# Avoid a "Cannot mix incompatible Qt library" error on Windows platforms +from qtpy import QtSvg # analysis:ignore + +# Avoid a bug in Qt: https://bugreports.qt.io/browse/QTBUG-46720 +from qtpy import QtWebEngineWidgets # analysis:ignore + +from qtawesome.iconic_font import FontError + +#============================================================================== +# Local imports +# NOTE: Move (if possible) import's of widgets and plugins exactly where they +# are needed in MainWindow to speed up perceived startup time (i.e. the time +# from clicking the Spyder icon to showing the splash screen). +#============================================================================== +from spyder import __version__ +from spyder import dependencies +from spyder.app.find_plugins import ( + find_external_plugins, find_internal_plugins) +from spyder.app.utils import ( + create_application, create_splash_screen, create_window, ORIGINAL_SYS_EXIT, + delete_debug_log_files, qt_message_handler, set_links_color, setup_logging, + set_opengl_implementation) +from spyder.api.plugin_registration.registry import PLUGIN_REGISTRY +from spyder.config.base import (_, DEV, get_conf_path, get_debug_level, + get_home_dir, get_module_source_path, + is_pynsist, running_in_mac_app, + running_under_pytest, STDERR) +from spyder.config.gui import is_dark_font_color +from spyder.config.main import OPEN_FILES_PORT +from spyder.config.manager import CONF +from spyder.config.utils import IMPORT_EXT, is_gtk_desktop +from spyder.otherplugins import get_spyderplugins_mods +from spyder.py3compat import configparser as cp, PY3, to_text_string +from spyder.utils import encoding, programs +from spyder.utils.icon_manager import ima +from spyder.utils.misc import (select_port, getcwd_or_home, + get_python_executable) +from spyder.utils.palette import QStylePalette +from spyder.utils.qthelpers import (create_action, add_actions, file_uri, + qapplication, start_file) +from spyder.utils.stylesheet import APP_STYLESHEET + +# Spyder API Imports +from spyder.api.exceptions import SpyderAPIError +from spyder.api.plugins import ( + Plugins, SpyderPlugin, SpyderPluginV2, SpyderDockablePlugin, + SpyderPluginWidget) + +#============================================================================== +# Windows only local imports +#============================================================================== +set_attached_console_visible = None +is_attached_console_visible = None +set_windows_appusermodelid = None +if os.name == 'nt': + from spyder.utils.windows import (set_attached_console_visible, + set_windows_appusermodelid) + +#============================================================================== +# Constants +#============================================================================== +# Module logger +logger = logging.getLogger(__name__) + +#============================================================================== +# Install Qt messaage handler +#============================================================================== +qInstallMessageHandler(qt_message_handler) + +#============================================================================== +# Main Window +#============================================================================== +class MainWindow(QMainWindow): + """Spyder main window""" + DOCKOPTIONS = ( + QMainWindow.AllowTabbedDocks | QMainWindow.AllowNestedDocks | + QMainWindow.AnimatedDocks + ) + SPYDER_PATH = get_conf_path('path') + SPYDER_NOT_ACTIVE_PATH = get_conf_path('not_active_path') + DEFAULT_LAYOUTS = 4 + INITIAL_CWD = getcwd_or_home() + + # Signals + restore_scrollbar_position = Signal() + sig_setup_finished = Signal() + all_actions_defined = Signal() + # type: (OrderedDict, OrderedDict) + sig_pythonpath_changed = Signal(object, object) + sig_open_external_file = Signal(str) + sig_resized = Signal("QResizeEvent") + sig_moved = Signal("QMoveEvent") + sig_layout_setup_ready = Signal(object) # Related to default layouts + + # ---- Plugin handling methods + # ------------------------------------------------------------------------ + def get_plugin(self, plugin_name, error=True): + """ + Return a plugin instance by providing the plugin class. + """ + if plugin_name in PLUGIN_REGISTRY: + return PLUGIN_REGISTRY.get_plugin(plugin_name) + + if error: + raise SpyderAPIError(f'Plugin "{plugin_name}" not found!') + + return None + + def get_dockable_plugins(self): + """Get a list of all dockable plugins.""" + dockable_plugins = [] + for plugin_name in PLUGIN_REGISTRY: + plugin = PLUGIN_REGISTRY.get_plugin(plugin_name) + if isinstance(plugin, (SpyderDockablePlugin, SpyderPluginWidget)): + dockable_plugins.append((plugin_name, plugin)) + return dockable_plugins + + def is_plugin_enabled(self, plugin_name): + """Determine if a given plugin is going to be loaded.""" + return PLUGIN_REGISTRY.is_plugin_enabled(plugin_name) + + def is_plugin_available(self, plugin_name): + """Determine if a given plugin is available.""" + return PLUGIN_REGISTRY.is_plugin_available(plugin_name) + + def show_status_message(self, message, timeout): + """ + Show a status message in Spyder Main Window. + """ + status_bar = self.statusBar() + if status_bar.isVisible(): + status_bar.showMessage(message, timeout) + + def show_plugin_compatibility_message(self, message): + """ + Show a compatibility message. + """ + messageBox = QMessageBox(self) + messageBox.setWindowModality(Qt.NonModal) + messageBox.setAttribute(Qt.WA_DeleteOnClose) + messageBox.setWindowTitle(_('Compatibility Check')) + messageBox.setText(message) + messageBox.setStandardButtons(QMessageBox.Ok) + messageBox.show() + + def register_plugin(self, plugin_name, external=False, omit_conf=False): + """ + Register a plugin in Spyder Main Window. + """ + plugin = PLUGIN_REGISTRY.get_plugin(plugin_name) + + self.set_splash(_("Loading {}...").format(plugin.get_name())) + logger.info("Loading {}...".format(plugin.NAME)) + + # Check plugin compatibility + is_compatible, message = plugin.check_compatibility() + plugin.is_compatible = is_compatible + plugin.get_description() + + if not is_compatible: + self.show_compatibility_message(message) + return + + # Connect Plugin Signals to main window methods + plugin.sig_exception_occurred.connect(self.handle_exception) + plugin.sig_free_memory_requested.connect(self.free_memory) + plugin.sig_quit_requested.connect(self.close) + plugin.sig_redirect_stdio_requested.connect( + self.redirect_internalshell_stdio) + plugin.sig_status_message_requested.connect(self.show_status_message) + + if isinstance(plugin, SpyderDockablePlugin): + plugin.sig_focus_changed.connect(self.plugin_focus_changed) + plugin.sig_switch_to_plugin_requested.connect( + self.switch_to_plugin) + plugin.sig_update_ancestor_requested.connect( + lambda: plugin.set_ancestor(self)) + + # Connect Main window Signals to plugin signals + self.sig_moved.connect(plugin.sig_mainwindow_moved) + self.sig_resized.connect(plugin.sig_mainwindow_resized) + + # Register plugin + plugin._register(omit_conf=omit_conf) + + if isinstance(plugin, SpyderDockablePlugin): + # Add dockwidget + self.add_dockwidget(plugin) + + # Update margins + margin = 0 + if CONF.get('main', 'use_custom_margin'): + margin = CONF.get('main', 'custom_margin') + plugin.update_margins(margin) + + if plugin_name == Plugins.Shortcuts: + for action, context, action_name in self.shortcut_queue: + self.register_shortcut(action, context, action_name) + self.shortcut_queue = [] + + logger.info("Registering shortcuts for {}...".format(plugin.NAME)) + for action_name, action in plugin.get_actions().items(): + context = (getattr(action, 'shortcut_context', plugin.NAME) + or plugin.NAME) + + if getattr(action, 'register_shortcut', True): + if isinstance(action_name, Enum): + action_name = action_name.value + if Plugins.Shortcuts in PLUGIN_REGISTRY: + self.register_shortcut(action, context, action_name) + else: + self.shortcut_queue.append((action, context, action_name)) + + if isinstance(plugin, SpyderDockablePlugin): + try: + context = '_' + name = 'switch to {}'.format(plugin.CONF_SECTION) + shortcut = CONF.get_shortcut(context, name, + plugin_name=plugin.CONF_SECTION) + except (cp.NoSectionError, cp.NoOptionError): + shortcut = None + + sc = QShortcut(QKeySequence(), self, + lambda: self.switch_to_plugin(plugin)) + sc.setContext(Qt.ApplicationShortcut) + plugin._shortcut = sc + + if Plugins.Shortcuts in PLUGIN_REGISTRY: + self.register_shortcut(sc, context, name) + self.register_shortcut( + plugin.toggle_view_action, context, name) + else: + self.shortcut_queue.append((sc, context, name)) + self.shortcut_queue.append( + (plugin.toggle_view_action, context, name)) + + def unregister_plugin(self, plugin): + """ + Unregister a plugin from the Spyder Main Window. + """ + logger.info("Unloading {}...".format(plugin.NAME)) + + # Disconnect all slots + signals = [ + plugin.sig_quit_requested, + plugin.sig_redirect_stdio_requested, + plugin.sig_status_message_requested, + ] + + for sig in signals: + try: + sig.disconnect() + except TypeError: + pass + + # Unregister shortcuts for actions + logger.info("Unregistering shortcuts for {}...".format(plugin.NAME)) + for action_name, action in plugin.get_actions().items(): + context = (getattr(action, 'shortcut_context', plugin.NAME) + or plugin.NAME) + self.shortcuts.unregister_shortcut(action, context, action_name) + + # Unregister switch to shortcut + shortcut = None + try: + context = '_' + name = 'switch to {}'.format(plugin.CONF_SECTION) + shortcut = CONF.get_shortcut(context, name, + plugin_name=plugin.CONF_SECTION) + except Exception: + pass + + if shortcut is not None: + self.shortcuts.unregister_shortcut( + plugin._shortcut, + context, + "Switch to {}".format(plugin.CONF_SECTION), + ) + + # Remove dockwidget + logger.info("Removing {} dockwidget...".format(plugin.NAME)) + self.remove_dockwidget(plugin) + + plugin._unregister() + + def create_plugin_conf_widget(self, plugin): + """ + Create configuration dialog box page widget. + """ + config_dialog = self.prefs_dialog_instance + if plugin.CONF_WIDGET_CLASS is not None and config_dialog is not None: + conf_widget = plugin.CONF_WIDGET_CLASS(plugin, config_dialog) + conf_widget.initialize() + return conf_widget + + @property + def last_plugin(self): + """ + Get last plugin with focus if it is a dockable widget. + + If a non-dockable plugin has the focus this will return by default + the Editor plugin. + """ + # Needed to prevent errors with the old API at + # spyder/plugins/base::_switch_to_plugin + return self.layouts.get_last_plugin() + + def maximize_dockwidget(self, restore=False): + """ + This is needed to prevent errors with the old API at + spyder/plugins/base::_switch_to_plugin. + + See spyder-ide/spyder#15164 + + Parameters + ---------- + restore : bool, optional + If the current dockwidget needs to be restored to its unmaximized + state. The default is False. + """ + self.layouts.maximize_dockwidget(restore=restore) + + def switch_to_plugin(self, plugin, force_focus=None): + """ + Switch to this plugin. + + Notes + ----- + This operation unmaximizes the current plugin (if any), raises + this plugin to view (if it's hidden) and gives it focus (if + possible). + """ + last_plugin = self.last_plugin + try: + # New API + if (last_plugin is not None + and last_plugin.get_widget().is_maximized + and last_plugin is not plugin): + self.layouts.maximize_dockwidget() + except AttributeError: + # Old API + if (last_plugin is not None and self.last_plugin._ismaximized + and last_plugin is not plugin): + self.layouts.maximize_dockwidget() + + try: + # New API + if not plugin.toggle_view_action.isChecked(): + plugin.toggle_view_action.setChecked(True) + plugin.get_widget().is_visible = False + except AttributeError: + # Old API + if not plugin._toggle_view_action.isChecked(): + plugin._toggle_view_action.setChecked(True) + plugin._widget._is_visible = False + + plugin.change_visibility(True, force_focus=force_focus) + + def remove_dockwidget(self, plugin): + """ + Remove a plugin QDockWidget from the main window. + """ + self.removeDockWidget(plugin.dockwidget) + try: + self.widgetlist.remove(plugin) + except ValueError: + pass + + def tabify_plugins(self, first, second): + """Tabify plugin dockwigdets.""" + self.tabifyDockWidget(first.dockwidget, second.dockwidget) + + def tabify_plugin(self, plugin, default=None): + """ + Tabify the plugin using the list of possible TABIFY options. + + Only do this if the dockwidget does not have more dockwidgets + in the same position and if the plugin is using the New API. + """ + def tabify_helper(plugin, next_to_plugins): + for next_to_plugin in next_to_plugins: + try: + self.tabify_plugins(next_to_plugin, plugin) + break + except SpyderAPIError as err: + logger.error(err) + + # If TABIFY not defined use the [default] + tabify = getattr(plugin, 'TABIFY', [default]) + if not isinstance(tabify, list): + next_to_plugins = [tabify] + else: + next_to_plugins = tabify + + # Check if TABIFY is not a list with None as unique value or a default + # list + if tabify in [[None], []]: + return False + + # Get the actual plugins from the names + next_to_plugins = [self.get_plugin(p) for p in next_to_plugins] + + # First time plugin starts + if plugin.get_conf('first_time', True): + if (isinstance(plugin, SpyderDockablePlugin) + and plugin.NAME != Plugins.Console): + logger.info( + "Tabify {} dockwidget for the first time...".format( + plugin.NAME)) + tabify_helper(plugin, next_to_plugins) + + # Show external plugins + if plugin.NAME in PLUGIN_REGISTRY.external_plugins: + plugin.get_widget().toggle_view(True) + + plugin.set_conf('enable', True) + plugin.set_conf('first_time', False) + else: + # This is needed to ensure plugins are placed correctly when + # switching layouts. + logger.info("Tabify {} dockwidget...".format(plugin.NAME)) + # Check if plugin has no other dockwidgets in the same position + if not bool(self.tabifiedDockWidgets(plugin.dockwidget)): + tabify_helper(plugin, next_to_plugins) + + return True + + def handle_exception(self, error_data): + """ + This method will call the handle exception method of the Console + plugin. It is provided as a signal on the Plugin API for convenience, + so that plugin do not need to explicitly call the Console plugin. + + Parameters + ---------- + error_data: dict + The dictionary containing error data. The expected keys are: + >>> error_data= { + "text": str, + "is_traceback": bool, + "repo": str, + "title": str, + "label": str, + "steps": str, + } + + Notes + ----- + The `is_traceback` key indicates if `text` contains plain text or a + Python error traceback. + + The `title` and `repo` keys indicate how the error data should + customize the report dialog and Github error submission. + + The `label` and `steps` keys allow customizing the content of the + error dialog. + """ + console = self.get_plugin(Plugins.Console, error=False) + if console: + console.handle_exception(error_data) + + def __init__(self, splash=None, options=None): + QMainWindow.__init__(self) + qapp = QApplication.instance() + + if running_under_pytest(): + self._proxy_style = None + else: + from spyder.utils.qthelpers import SpyderProxyStyle + # None is needed, see: https://bugreports.qt.io/browse/PYSIDE-922 + self._proxy_style = SpyderProxyStyle(None) + + # Enabling scaling for high dpi + qapp.setAttribute(Qt.AA_UseHighDpiPixmaps) + + # Set Windows app icon to use .ico file + if os.name == "nt": + qapp.setWindowIcon(ima.get_icon("windows_app_icon")) + + # Set default style + self.default_style = str(qapp.style().objectName()) + + # Save command line options for plugins to access them + self._cli_options = options + + logger.info("Start of MainWindow constructor") + + def signal_handler(signum, frame=None): + """Handler for signals.""" + sys.stdout.write('Handling signal: %s\n' % signum) + sys.stdout.flush() + QApplication.quit() + + if os.name == "nt": + try: + import win32api + win32api.SetConsoleCtrlHandler(signal_handler, True) + except ImportError: + pass + else: + signal.signal(signal.SIGTERM, signal_handler) + if not DEV: + # Make spyder quit when presing ctrl+C in the console + # In DEV Ctrl+C doesn't quit, because it helps to + # capture the traceback when spyder freezes + signal.signal(signal.SIGINT, signal_handler) + + # Use a custom Qt stylesheet + if sys.platform == 'darwin': + spy_path = get_module_source_path('spyder') + img_path = osp.join(spy_path, 'images') + mac_style = open(osp.join(spy_path, 'app', 'mac_stylesheet.qss')).read() + mac_style = mac_style.replace('$IMAGE_PATH', img_path) + self.setStyleSheet(mac_style) + + # Shortcut management data + self.shortcut_data = [] + self.shortcut_queue = [] + + # Handle Spyder path + self.path = () + self.not_active_path = () + self.project_path = () + self._path_manager = None + + # New API + self._APPLICATION_TOOLBARS = OrderedDict() + self._STATUS_WIDGETS = OrderedDict() + # Mapping of new plugin identifiers vs old attributtes + # names given for plugins or to prevent collisions with other + # attributes, i.e layout (Qt) vs layout (SpyderPluginV2) + self._INTERNAL_PLUGINS_MAPPING = { + 'console': Plugins.Console, + 'maininterpreter': Plugins.MainInterpreter, + 'outlineexplorer': Plugins.OutlineExplorer, + 'variableexplorer': Plugins.VariableExplorer, + 'ipyconsole': Plugins.IPythonConsole, + 'workingdirectory': Plugins.WorkingDirectory, + 'projects': Plugins.Projects, + 'findinfiles': Plugins.Find, + 'layouts': Plugins.Layout, + } + + self.thirdparty_plugins = [] + + # File switcher + self.switcher = None + + # Preferences + self.prefs_dialog_size = None + self.prefs_dialog_instance = None + + # Actions + self.undo_action = None + self.redo_action = None + self.copy_action = None + self.cut_action = None + self.paste_action = None + self.selectall_action = None + + # Menu bars + self.edit_menu = None + self.edit_menu_actions = [] + self.search_menu = None + self.search_menu_actions = [] + self.source_menu = None + self.source_menu_actions = [] + self.run_menu = None + self.run_menu_actions = [] + self.debug_menu = None + self.debug_menu_actions = [] + + # TODO: Move to corresponding Plugins + self.main_toolbar = None + self.main_toolbar_actions = [] + self.file_toolbar = None + self.file_toolbar_actions = [] + self.run_toolbar = None + self.run_toolbar_actions = [] + self.debug_toolbar = None + self.debug_toolbar_actions = [] + + self.menus = [] + + if running_under_pytest(): + # Show errors in internal console when testing. + CONF.set('main', 'show_internal_errors', False) + + self.CURSORBLINK_OSDEFAULT = QApplication.cursorFlashTime() + + if set_windows_appusermodelid != None: + res = set_windows_appusermodelid() + logger.info("appusermodelid: %s", res) + + # Setting QTimer if running in travis + test_app = os.environ.get('TEST_CI_APP') + if test_app is not None: + app = qapplication() + timer_shutdown_time = 30000 + self.timer_shutdown = QTimer(self) + self.timer_shutdown.timeout.connect(app.quit) + self.timer_shutdown.start(timer_shutdown_time) + + # Showing splash screen + self.splash = splash + if CONF.get('main', 'current_version', '') != __version__: + CONF.set('main', 'current_version', __version__) + # Execute here the actions to be performed only once after + # each update (there is nothing there for now, but it could + # be useful some day...) + + # List of satellite widgets (registered in add_dockwidget): + self.widgetlist = [] + + # Flags used if closing() is called by the exit() shell command + self.already_closed = False + self.is_starting_up = True + self.is_setting_up = True + + self.window_size = None + self.window_position = None + + # To keep track of the last focused widget + self.last_focused_widget = None + self.previous_focused_widget = None + + # Server to open external files on a single instance + # This is needed in order to handle socket creation problems. + # See spyder-ide/spyder#4132. + if os.name == 'nt': + try: + self.open_files_server = socket.socket(socket.AF_INET, + socket.SOCK_STREAM, + socket.IPPROTO_TCP) + except OSError: + self.open_files_server = None + QMessageBox.warning(None, "Spyder", + _("An error occurred while creating a socket needed " + "by Spyder. Please, try to run as an Administrator " + "from cmd.exe the following command and then " + "restart your computer:

netsh winsock reset " + "
").format( + color=QStylePalette.COLOR_BACKGROUND_4)) + else: + self.open_files_server = socket.socket(socket.AF_INET, + socket.SOCK_STREAM, + socket.IPPROTO_TCP) + + # Apply main window settings + self.apply_settings() + + # To set all dockwidgets tabs to be on top (in case we want to do it + # in the future) + # self.setTabPosition(Qt.AllDockWidgetAreas, QTabWidget.North) + + logger.info("End of MainWindow constructor") + + # ---- Window setup + def _update_shortcuts_in_panes_menu(self, show=True): + """ + Display the shortcut for the "Switch to plugin..." on the toggle view + action of the plugins displayed in the Help/Panes menu. + + Notes + ----- + SpyderDockablePlugins provide two actions that function as a single + action. The `Switch to Plugin...` action has an assignable shortcut + via the shortcut preferences. The `Plugin toggle View` in the `View` + application menu, uses a custom `Toggle view action` that displays the + shortcut assigned to the `Switch to Plugin...` action, but is not + triggered by that shortcut. + """ + for plugin_name in PLUGIN_REGISTRY: + plugin = PLUGIN_REGISTRY.get_plugin(plugin_name) + if isinstance(plugin, SpyderDockablePlugin): + try: + # New API + action = plugin.toggle_view_action + except AttributeError: + # Old API + action = plugin._toggle_view_action + + if show: + section = plugin.CONF_SECTION + try: + context = '_' + name = 'switch to {}'.format(section) + shortcut = CONF.get_shortcut( + context, name, plugin_name=section) + except (cp.NoSectionError, cp.NoOptionError): + shortcut = QKeySequence() + else: + shortcut = QKeySequence() + + action.setShortcut(shortcut) + + def setup(self): + """Setup main window.""" + PLUGIN_REGISTRY.sig_plugin_ready.connect( + lambda plugin_name, omit_conf: self.register_plugin( + plugin_name, omit_conf=omit_conf)) + + PLUGIN_REGISTRY.set_main(self) + + # TODO: Remove circular dependency between help and ipython console + # and remove this import. Help plugin should take care of it + from spyder.plugins.help.utils.sphinxify import CSS_PATH, DARK_CSS_PATH + logger.info("*** Start of MainWindow setup ***") + logger.info("Updating PYTHONPATH") + path_dict = self.get_spyder_pythonpath_dict() + self.update_python_path(path_dict) + + logger.info("Applying theme configuration...") + ui_theme = CONF.get('appearance', 'ui_theme') + color_scheme = CONF.get('appearance', 'selected') + + 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) + self.statusBar().setStyleSheet(dark_qss) + css_path = DARK_CSS_PATH + + 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) + self.statusBar().setStyleSheet(light_qss) + css_path = CSS_PATH + + elif ui_theme == 'automatic': + 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) + self.statusBar().setStyleSheet(dark_qss) + css_path = DARK_CSS_PATH + else: + light_qss = str(APP_STYLESHEET) + self.setStyleSheet(light_qss) + self.statusBar().setStyleSheet(light_qss) + css_path = CSS_PATH + + # Set css_path as a configuration to be used by the plugins + CONF.set('appearance', 'css_path', css_path) + + # Status bar + status = self.statusBar() + status.setObjectName("StatusBar") + status.showMessage(_("Welcome to Spyder!"), 5000) + + # Switcher instance + logger.info("Loading switcher...") + self.create_switcher() + + # Load and register internal and external plugins + external_plugins = find_external_plugins() + internal_plugins = find_internal_plugins() + all_plugins = external_plugins.copy() + all_plugins.update(internal_plugins.copy()) + + # Determine 'enable' config for the plugins that have it + enabled_plugins = {} + registry_internal_plugins = {} + registry_external_plugins = {} + for plugin in all_plugins.values(): + plugin_name = plugin.NAME + # Disable panes that use web widgets (currently Help and Online + # Help) if the user asks for it. + # See spyder-ide/spyder#16518 + if self._cli_options.no_web_widgets: + if "help" in plugin_name: + continue + plugin_main_attribute_name = ( + self._INTERNAL_PLUGINS_MAPPING[plugin_name] + if plugin_name in self._INTERNAL_PLUGINS_MAPPING + else plugin_name) + if plugin_name in internal_plugins: + registry_internal_plugins[plugin_name] = ( + plugin_main_attribute_name, plugin) + else: + registry_external_plugins[plugin_name] = ( + plugin_main_attribute_name, plugin) + try: + if CONF.get(plugin_main_attribute_name, "enable"): + enabled_plugins[plugin_name] = plugin + PLUGIN_REGISTRY.set_plugin_enabled(plugin_name) + except (cp.NoOptionError, cp.NoSectionError): + enabled_plugins[plugin_name] = plugin + PLUGIN_REGISTRY.set_plugin_enabled(plugin_name) + + PLUGIN_REGISTRY.set_all_internal_plugins(registry_internal_plugins) + PLUGIN_REGISTRY.set_all_external_plugins(registry_external_plugins) + + # Instantiate internal Spyder 5 plugins + for plugin_name in internal_plugins: + if plugin_name in enabled_plugins: + PluginClass = internal_plugins[plugin_name] + if issubclass(PluginClass, SpyderPluginV2): + PLUGIN_REGISTRY.register_plugin(self, PluginClass, + external=False) + + # Instantiate internal Spyder 4 plugins + for plugin_name in internal_plugins: + if plugin_name in enabled_plugins: + PluginClass = internal_plugins[plugin_name] + if issubclass(PluginClass, SpyderPlugin): + plugin_instance = PLUGIN_REGISTRY.register_plugin( + self, PluginClass, external=False) + self.preferences.register_plugin_preferences( + plugin_instance) + + # Instantiate external Spyder 5 plugins + for plugin_name in external_plugins: + if plugin_name in enabled_plugins: + PluginClass = external_plugins[plugin_name] + try: + plugin_instance = PLUGIN_REGISTRY.register_plugin( + self, PluginClass, external=True) + except Exception as error: + print("%s: %s" % (PluginClass, str(error)), file=STDERR) + traceback.print_exc(file=STDERR) + + self.set_splash(_("Loading old third-party plugins...")) + for mod in get_spyderplugins_mods(): + try: + plugin = PLUGIN_REGISTRY.register_plugin(self, mod, + external=True) + if plugin.check_compatibility()[0]: + if hasattr(plugin, 'CONFIGWIDGET_CLASS'): + self.preferences.register_plugin_preferences(plugin) + + if not hasattr(plugin, 'COMPLETION_PROVIDER_NAME'): + self.thirdparty_plugins.append(plugin) + + # Add to dependencies dialog + module = mod.__name__ + name = module.replace('_', '-') + if plugin.DESCRIPTION: + description = plugin.DESCRIPTION + else: + description = plugin.get_plugin_title() + + dependencies.add(module, name, description, + '', None, kind=dependencies.PLUGIN) + except TypeError: + # Fixes spyder-ide/spyder#13977 + pass + except Exception as error: + print("%s: %s" % (mod, str(error)), file=STDERR) + traceback.print_exc(file=STDERR) + + # Set window title + self.set_window_title() + + # Menus + # TODO: Remove when all menus are migrated to use the Main Menu Plugin + logger.info("Creating Menus...") + from spyder.plugins.mainmenu.api import ( + ApplicationMenus, ToolsMenuSections, FileMenuSections) + mainmenu = self.mainmenu + self.edit_menu = mainmenu.get_application_menu("edit_menu") + self.search_menu = mainmenu.get_application_menu("search_menu") + self.source_menu = mainmenu.get_application_menu("source_menu") + self.source_menu.aboutToShow.connect(self.update_source_menu) + self.run_menu = mainmenu.get_application_menu("run_menu") + self.debug_menu = mainmenu.get_application_menu("debug_menu") + + # Switcher shortcuts + self.file_switcher_action = create_action( + self, + _('File switcher...'), + icon=ima.icon('filelist'), + tip=_('Fast switch between files'), + triggered=self.open_switcher, + context=Qt.ApplicationShortcut, + id_='file_switcher') + self.register_shortcut(self.file_switcher_action, context="_", + name="File switcher") + self.symbol_finder_action = create_action( + self, _('Symbol finder...'), + icon=ima.icon('symbol_find'), + tip=_('Fast symbol search in file'), + triggered=self.open_symbolfinder, + context=Qt.ApplicationShortcut, + id_='symbol_finder') + self.register_shortcut(self.symbol_finder_action, context="_", + name="symbol finder", add_shortcut_to_tip=True) + + def create_edit_action(text, tr_text, icon): + textseq = text.split(' ') + method_name = textseq[0].lower()+"".join(textseq[1:]) + action = create_action(self, tr_text, + icon=icon, + triggered=self.global_callback, + data=method_name, + context=Qt.WidgetShortcut) + self.register_shortcut(action, "Editor", text) + return action + + self.undo_action = create_edit_action('Undo', _('Undo'), + ima.icon('undo')) + self.redo_action = create_edit_action('Redo', _('Redo'), + ima.icon('redo')) + self.copy_action = create_edit_action('Copy', _('Copy'), + ima.icon('editcopy')) + self.cut_action = create_edit_action('Cut', _('Cut'), + ima.icon('editcut')) + self.paste_action = create_edit_action('Paste', _('Paste'), + ima.icon('editpaste')) + self.selectall_action = create_edit_action("Select All", + _("Select All"), + ima.icon('selectall')) + + self.edit_menu_actions += [self.undo_action, self.redo_action, + None, self.cut_action, self.copy_action, + self.paste_action, self.selectall_action, + None] + if self.get_plugin(Plugins.Editor, error=False): + self.edit_menu_actions += self.editor.edit_menu_actions + + switcher_actions = [ + self.file_switcher_action, + self.symbol_finder_action + ] + for switcher_action in switcher_actions: + mainmenu.add_item_to_application_menu( + switcher_action, + menu_id=ApplicationMenus.File, + section=FileMenuSections.Switcher, + before_section=FileMenuSections.Restart) + self.set_splash("") + + # Toolbars + # TODO: Remove after finishing the migration + logger.info("Creating toolbars...") + toolbar = self.toolbar + self.file_toolbar = toolbar.get_application_toolbar("file_toolbar") + self.run_toolbar = toolbar.get_application_toolbar("run_toolbar") + self.debug_toolbar = toolbar.get_application_toolbar("debug_toolbar") + self.main_toolbar = toolbar.get_application_toolbar("main_toolbar") + + # Tools + External Tools (some of this depends on the Application + # plugin) + logger.info("Creating Tools menu...") + + spyder_path_action = create_action( + self, + _("PYTHONPATH manager"), + None, icon=ima.icon('pythonpath'), + triggered=self.show_path_manager, + tip=_("PYTHONPATH manager"), + id_='spyder_path_action') + from spyder.plugins.application.container import ( + ApplicationActions, WinUserEnvDialog) + winenv_action = None + if WinUserEnvDialog: + winenv_action = ApplicationActions.SpyderWindowsEnvVariables + mainmenu.add_item_to_application_menu( + spyder_path_action, + menu_id=ApplicationMenus.Tools, + section=ToolsMenuSections.Tools, + before=winenv_action, + before_section=ToolsMenuSections.External + ) + + # Main toolbar + from spyder.plugins.toolbar.api import ( + ApplicationToolbars, MainToolbarSections) + self.toolbar.add_item_to_application_toolbar( + spyder_path_action, + toolbar_id=ApplicationToolbars.Main, + section=MainToolbarSections.ApplicationSection + ) + + self.set_splash(_("Setting up main window...")) + + # TODO: Migrate to use the MainMenu Plugin instead of list of actions + # Filling out menu/toolbar entries: + add_actions(self.edit_menu, self.edit_menu_actions) + add_actions(self.search_menu, self.search_menu_actions) + add_actions(self.source_menu, self.source_menu_actions) + add_actions(self.run_menu, self.run_menu_actions) + add_actions(self.debug_menu, self.debug_menu_actions) + + # Emitting the signal notifying plugins that main window menu and + # toolbar actions are all defined: + self.all_actions_defined.emit() + + def __getattr__(self, attr): + """ + Redefinition of __getattr__ to enable access to plugins. + + Loaded plugins can be accessed as attributes of the mainwindow + as before, e.g self.console or self.main.console, preserving the + same accessor as before. + """ + # Mapping of new plugin identifiers vs old attributtes + # names given for plugins + try: + if attr in self._INTERNAL_PLUGINS_MAPPING.keys(): + return self.get_plugin( + self._INTERNAL_PLUGINS_MAPPING[attr], error=False) + return self.get_plugin(attr) + except SpyderAPIError: + pass + return super().__getattr__(attr) + + def pre_visible_setup(self): + """ + Actions to be performed before the main window is visible. + + The actions here are related with setting up the main window. + """ + logger.info("Setting up window...") + + for plugin_name in PLUGIN_REGISTRY: + plugin_instance = PLUGIN_REGISTRY.get_plugin(plugin_name) + try: + plugin_instance.before_mainwindow_visible() + except AttributeError: + pass + + # Tabify external plugins which were installed after Spyder was + # installed. + # Note: This is only necessary the first time a plugin is loaded. + # Afterwards, the plugin placement is recorded on the window hexstate, + # which is loaded by the layouts plugin during the next session. + for plugin_name in PLUGIN_REGISTRY.external_plugins: + plugin_instance = PLUGIN_REGISTRY.get_plugin(plugin_name) + if plugin_instance.get_conf('first_time', True): + self.tabify_plugin(plugin_instance, Plugins.Console) + + if self.splash is not None: + self.splash.hide() + + # Menu about to show + for child in self.menuBar().children(): + if isinstance(child, QMenu): + try: + child.aboutToShow.connect(self.update_edit_menu) + child.aboutToShow.connect(self.update_search_menu) + except TypeError: + pass + + # Register custom layouts + for plugin_name in PLUGIN_REGISTRY.external_plugins: + plugin_instance = PLUGIN_REGISTRY.get_plugin(plugin_name) + if hasattr(plugin_instance, 'CUSTOM_LAYOUTS'): + if isinstance(plugin_instance.CUSTOM_LAYOUTS, list): + for custom_layout in plugin_instance.CUSTOM_LAYOUTS: + self.layouts.register_layout( + self, custom_layout) + else: + logger.info( + 'Unable to load custom layouts for {}. ' + 'Expecting a list of layout classes but got {}' + .format(plugin_name, plugin_instance.CUSTOM_LAYOUTS) + ) + + # Needed to ensure dockwidgets/panes layout size distribution + # when a layout state is already present. + # See spyder-ide/spyder#17945 + if self.layouts is not None and CONF.get('main', 'window/state', None): + self.layouts.before_mainwindow_visible() + + logger.info("*** End of MainWindow setup ***") + self.is_starting_up = False + + def post_visible_setup(self): + """ + Actions to be performed only after the main window's `show` method + is triggered. + """ + # Process pending events and hide splash before loading the + # previous session. + QApplication.processEvents() + if self.splash is not None: + self.splash.hide() + + # Call on_mainwindow_visible for all plugins. + for plugin_name in PLUGIN_REGISTRY: + plugin = PLUGIN_REGISTRY.get_plugin(plugin_name) + try: + plugin.on_mainwindow_visible() + QApplication.processEvents() + except AttributeError: + pass + + self.restore_scrollbar_position.emit() + + # Server to maintain just one Spyder instance and open files in it if + # the user tries to start other instances with + # $ spyder foo.py + if ( + CONF.get('main', 'single_instance') and + not self._cli_options.new_instance and + self.open_files_server + ): + t = threading.Thread(target=self.start_open_files_server) + t.daemon = True + t.start() + + # Connect the window to the signal emitted by the previous server + # when it gets a client connected to it + self.sig_open_external_file.connect(self.open_external_file) + + # Update plugins toggle actions to show the "Switch to" plugin shortcut + self._update_shortcuts_in_panes_menu() + + # Reopen last session if no project is active + # NOTE: This needs to be after the calls to on_mainwindow_visible + self.reopen_last_session() + + # Raise the menuBar to the top of the main window widget's stack + # Fixes spyder-ide/spyder#3887. + self.menuBar().raise_() + + # To avoid regressions. We shouldn't have loaded the modules + # below at this point. + if DEV is not None: + assert 'pandas' not in sys.modules + assert 'matplotlib' not in sys.modules + + # Restore undocked plugins + self.restore_undocked_plugins() + + # Notify that the setup of the mainwindow was finished + self.is_setting_up = False + self.sig_setup_finished.emit() + + def reopen_last_session(self): + """ + Reopen last session if no project is active. + + This can't be moved to on_mainwindow_visible in the editor because we + need to let the same method on Projects run first. + """ + projects = self.get_plugin(Plugins.Projects, error=False) + editor = self.get_plugin(Plugins.Editor, error=False) + reopen_last_session = False + + if projects: + if projects.get_active_project() is None: + reopen_last_session = True + else: + reopen_last_session = True + + if editor and reopen_last_session: + editor.setup_open_files(close_previous_files=False) + + def restore_undocked_plugins(self): + """Restore plugins that were undocked in the previous session.""" + logger.info("Restoring undocked plugins from the previous session") + + for plugin_name in PLUGIN_REGISTRY: + plugin = PLUGIN_REGISTRY.get_plugin(plugin_name) + if isinstance(plugin, SpyderDockablePlugin): + if plugin.get_conf('undocked_on_window_close', default=False): + plugin.get_widget().create_window() + elif isinstance(plugin, SpyderPluginWidget): + if plugin.get_option('undocked_on_window_close', + default=False): + plugin._create_window() + + def set_window_title(self): + """Set window title.""" + if DEV is not None: + title = u"Spyder %s (Python %s.%s)" % (__version__, + sys.version_info[0], + sys.version_info[1]) + elif running_in_mac_app() or is_pynsist(): + title = "Spyder" + else: + title = u"Spyder (Python %s.%s)" % (sys.version_info[0], + sys.version_info[1]) + + if get_debug_level(): + title += u" [DEBUG MODE %d]" % get_debug_level() + + window_title = self._cli_options.window_title + if window_title is not None: + title += u' -- ' + to_text_string(window_title) + + # TODO: Remove self.projects reference once there's an API for setting + # window title. + projects = self.get_plugin(Plugins.Projects, error=False) + if projects: + path = projects.get_active_project_path() + if path: + path = path.replace(get_home_dir(), u'~') + title = u'{0} - {1}'.format(path, title) + + self.base_title = title + self.setWindowTitle(self.base_title) + + # TODO: To be removed after all actions are moved to their corresponding + # plugins + def register_shortcut(self, qaction_or_qshortcut, context, name, + add_shortcut_to_tip=True, plugin_name=None): + shortcuts = self.get_plugin(Plugins.Shortcuts, error=False) + if shortcuts: + shortcuts.register_shortcut( + qaction_or_qshortcut, + context, + name, + add_shortcut_to_tip=add_shortcut_to_tip, + plugin_name=plugin_name, + ) + + # --- Other + def update_source_menu(self): + """Update source menu options that vary dynamically.""" + # This is necessary to avoid an error at startup. + # Fixes spyder-ide/spyder#14901 + try: + editor = self.get_plugin(Plugins.Editor, error=False) + if editor: + editor.refresh_formatter_name() + except AttributeError: + pass + + def free_memory(self): + """Free memory after event.""" + gc.collect() + + def plugin_focus_changed(self): + """Focus has changed from one plugin to another""" + self.update_edit_menu() + self.update_search_menu() + + def show_shortcuts(self, menu): + """Show action shortcuts in menu.""" + menu_actions = menu.actions() + for action in menu_actions: + if getattr(action, '_shown_shortcut', False): + # This is a SpyderAction + if action._shown_shortcut is not None: + action.setShortcut(action._shown_shortcut) + elif action.menu() is not None: + # This is submenu, so we need to call this again + self.show_shortcuts(action.menu()) + else: + # We don't need to do anything for other elements + continue + + def hide_shortcuts(self, menu): + """Hide action shortcuts in menu.""" + menu_actions = menu.actions() + for action in menu_actions: + if getattr(action, '_shown_shortcut', False): + # This is a SpyderAction + if action._shown_shortcut is not None: + action.setShortcut(QKeySequence()) + elif action.menu() is not None: + # This is submenu, so we need to call this again + self.hide_shortcuts(action.menu()) + else: + # We don't need to do anything for other elements + continue + + def hide_options_menus(self): + """Hide options menu when menubar is pressed in macOS.""" + for plugin in self.widgetlist + self.thirdparty_plugins: + if plugin.CONF_SECTION == 'editor': + editorstack = self.editor.get_current_editorstack() + editorstack.menu.hide() + else: + try: + # New API + plugin.options_menu.hide() + except AttributeError: + # Old API + plugin._options_menu.hide() + + def get_focus_widget_properties(self): + """Get properties of focus widget + Returns tuple (widget, properties) where properties is a tuple of + booleans: (is_console, not_readonly, readwrite_editor)""" + from spyder.plugins.editor.widgets.base import TextEditBaseWidget + from spyder.plugins.ipythonconsole.widgets import ControlWidget + widget = QApplication.focusWidget() + + textedit_properties = None + if isinstance(widget, (TextEditBaseWidget, ControlWidget)): + console = isinstance(widget, ControlWidget) + not_readonly = not widget.isReadOnly() + readwrite_editor = not_readonly and not console + textedit_properties = (console, not_readonly, readwrite_editor) + return widget, textedit_properties + + def update_edit_menu(self): + """Update edit menu""" + widget, textedit_properties = self.get_focus_widget_properties() + if textedit_properties is None: # widget is not an editor/console + return + # !!! Below this line, widget is expected to be a QPlainTextEdit + # instance + console, not_readonly, readwrite_editor = textedit_properties + + if hasattr(self, 'editor'): + # Editor has focus and there is no file opened in it + if (not console and not_readonly and self.editor + and not self.editor.is_file_opened()): + return + + # Disabling all actions to begin with + for child in self.edit_menu.actions(): + child.setEnabled(False) + + self.selectall_action.setEnabled(True) + + # Undo, redo + self.undo_action.setEnabled( readwrite_editor \ + and widget.document().isUndoAvailable() ) + self.redo_action.setEnabled( readwrite_editor \ + and widget.document().isRedoAvailable() ) + + # Copy, cut, paste, delete + has_selection = widget.has_selected_text() + self.copy_action.setEnabled(has_selection) + self.cut_action.setEnabled(has_selection and not_readonly) + self.paste_action.setEnabled(not_readonly) + + # Comment, uncomment, indent, unindent... + if not console and not_readonly: + # This is the editor and current file is writable + if self.get_plugin(Plugins.Editor, error=False): + for action in self.editor.edit_menu_actions: + action.setEnabled(True) + + def update_search_menu(self): + """Update search menu""" + # Disabling all actions except the last one + # (which is Find in files) to begin with + for child in self.search_menu.actions()[:-1]: + child.setEnabled(False) + + widget, textedit_properties = self.get_focus_widget_properties() + if textedit_properties is None: # widget is not an editor/console + return + + # !!! Below this line, widget is expected to be a QPlainTextEdit + # instance + console, not_readonly, readwrite_editor = textedit_properties + + # Find actions only trigger an effect in the Editor + if not console: + for action in self.search_menu.actions(): + try: + action.setEnabled(True) + except RuntimeError: + pass + + # Disable the replace action for read-only files + if len(self.search_menu_actions) > 3: + self.search_menu_actions[3].setEnabled(readwrite_editor) + + def createPopupMenu(self): + return self.application.get_application_context_menu(parent=self) + + def set_splash(self, message): + """Set splash message""" + if self.splash is None: + return + if message: + logger.info(message) + self.splash.show() + self.splash.showMessage(message, + int(Qt.AlignBottom | Qt.AlignCenter | + Qt.AlignAbsolute), + QColor(Qt.white)) + QApplication.processEvents() + + def closeEvent(self, event): + """closeEvent reimplementation""" + if self.closing(True): + event.accept() + else: + event.ignore() + + def resizeEvent(self, event): + """Reimplement Qt method""" + if not self.isMaximized() and not self.layouts.get_fullscreen_flag(): + self.window_size = self.size() + QMainWindow.resizeEvent(self, event) + + # To be used by the tour to be able to resize + self.sig_resized.emit(event) + + def moveEvent(self, event): + """Reimplement Qt method""" + if hasattr(self, 'layouts'): + if not self.isMaximized() and not self.layouts.get_fullscreen_flag(): + self.window_position = self.pos() + QMainWindow.moveEvent(self, event) + # To be used by the tour to be able to move + self.sig_moved.emit(event) + + def hideEvent(self, event): + """Reimplement Qt method""" + try: + for plugin in (self.widgetlist + self.thirdparty_plugins): + # TODO: Remove old API + try: + # New API + if plugin.get_widget().isAncestorOf( + self.last_focused_widget): + plugin.change_visibility(True) + except AttributeError: + # Old API + if plugin.isAncestorOf(self.last_focused_widget): + plugin._visibility_changed(True) + + QMainWindow.hideEvent(self, event) + except RuntimeError: + QMainWindow.hideEvent(self, event) + + def change_last_focused_widget(self, old, now): + """To keep track of to the last focused widget""" + if (now is None and QApplication.activeWindow() is not None): + QApplication.activeWindow().setFocus() + self.last_focused_widget = QApplication.focusWidget() + elif now is not None: + self.last_focused_widget = now + + self.previous_focused_widget = old + + def closing(self, cancelable=False, close_immediately=False): + """Exit tasks""" + if self.already_closed or self.is_starting_up: + return True + + self.plugin_registry = PLUGIN_REGISTRY + + if cancelable and CONF.get('main', 'prompt_on_exit'): + reply = QMessageBox.critical(self, 'Spyder', + 'Do you really want to exit?', + QMessageBox.Yes, QMessageBox.No) + if reply == QMessageBox.No: + return False + + can_close = self.plugin_registry.delete_all_plugins( + excluding={Plugins.Layout}, + close_immediately=close_immediately) + + if not can_close and not close_immediately: + return False + + # Save window settings *after* closing all plugin windows, in order + # to show them in their previous locations in the next session. + # Fixes spyder-ide/spyder#12139 + prefix = 'window' + '/' + if self.layouts is not None: + self.layouts.save_current_window_settings(prefix) + try: + layouts_container = self.layouts.get_container() + if layouts_container: + layouts_container.close() + layouts_container.deleteLater() + self.layouts.deleteLater() + self.plugin_registry.delete_plugin( + Plugins.Layout, teardown=False) + except RuntimeError: + pass + + self.already_closed = True + + if CONF.get('main', 'single_instance') and self.open_files_server: + self.open_files_server.close() + + QApplication.processEvents() + + return True + + def add_dockwidget(self, plugin): + """ + Add a plugin QDockWidget to the main window. + """ + try: + # New API + if plugin.is_compatible: + dockwidget, location = plugin.create_dockwidget(self) + self.addDockWidget(location, dockwidget) + self.widgetlist.append(plugin) + except AttributeError: + # Old API + if plugin._is_compatible: + dockwidget, location = plugin._create_dockwidget() + self.addDockWidget(location, dockwidget) + self.widgetlist.append(plugin) + + def global_callback(self): + """Global callback""" + widget = QApplication.focusWidget() + action = self.sender() + callback = from_qvariant(action.data(), to_text_string) + from spyder.plugins.editor.widgets.base import TextEditBaseWidget + from spyder.plugins.ipythonconsole.widgets import ControlWidget + + if isinstance(widget, (TextEditBaseWidget, ControlWidget)): + getattr(widget, callback)() + else: + return + + def redirect_internalshell_stdio(self, state): + console = self.get_plugin(Plugins.Console, error=False) + if console: + if state: + console.redirect_stds() + else: + console.restore_stds() + + def open_external_console(self, fname, wdir, args, interact, debug, python, + python_args, systerm, post_mortem=False): + """Open external console""" + if systerm: + # Running script in an external system terminal + try: + if CONF.get('main_interpreter', 'default'): + executable = get_python_executable() + else: + executable = CONF.get('main_interpreter', 'executable') + pypath = CONF.get('main', 'spyder_pythonpath', None) + programs.run_python_script_in_terminal( + fname, wdir, args, interact, debug, python_args, + executable, pypath) + except NotImplementedError: + QMessageBox.critical(self, _("Run"), + _("Running an external system terminal " + "is not supported on platform %s." + ) % os.name) + + def open_file(self, fname, external=False): + """ + Open filename with the appropriate application + Redirect to the right widget (txt -> editor, spydata -> workspace, ...) + or open file outside Spyder (if extension is not supported) + """ + fname = to_text_string(fname) + ext = osp.splitext(fname)[1] + editor = self.get_plugin(Plugins.Editor, error=False) + variableexplorer = self.get_plugin( + Plugins.VariableExplorer, error=False) + + if encoding.is_text_file(fname): + if editor: + editor.load(fname) + elif variableexplorer is not None and ext in IMPORT_EXT: + variableexplorer.get_widget().import_data(fname) + elif not external: + fname = file_uri(fname) + start_file(fname) + + def get_initial_working_directory(self): + """Return the initial working directory.""" + return self.INITIAL_CWD + + def open_external_file(self, fname): + """ + Open external files that can be handled either by the Editor or the + variable explorer inside Spyder. + """ + # Check that file exists + fname = encoding.to_unicode_from_fs(fname) + initial_cwd = self.get_initial_working_directory() + if osp.exists(osp.join(initial_cwd, fname)): + fpath = osp.join(initial_cwd, fname) + elif osp.exists(fname): + fpath = fname + else: + return + + # Don't open script that starts Spyder at startup. + # Fixes issue spyder-ide/spyder#14483 + if sys.platform == 'darwin' and 'bin/spyder' in fname: + return + + if osp.isfile(fpath): + self.open_file(fpath, external=True) + elif osp.isdir(fpath): + QMessageBox.warning( + self, _("Error"), + _('To open {fpath} as a project with Spyder, ' + 'please use spyder -p "{fname}".') + .format(fpath=osp.normpath(fpath), fname=fname) + ) + + # --- Path Manager + # ------------------------------------------------------------------------ + def load_python_path(self): + """Load path stored in Spyder configuration folder.""" + if osp.isfile(self.SPYDER_PATH): + with open(self.SPYDER_PATH, 'r', encoding='utf-8') as f: + path = f.read().splitlines() + self.path = tuple(name for name in path if osp.isdir(name)) + + if osp.isfile(self.SPYDER_NOT_ACTIVE_PATH): + with open(self.SPYDER_NOT_ACTIVE_PATH, 'r', + encoding='utf-8') as f: + not_active_path = f.read().splitlines() + self.not_active_path = tuple(name for name in not_active_path + if osp.isdir(name)) + + def save_python_path(self, new_path_dict): + """ + Save path in Spyder configuration folder. + + `new_path_dict` is an OrderedDict that has the new paths as keys and + the state as values. The state is `True` for active and `False` for + inactive. + """ + path = [p for p in new_path_dict] + not_active_path = [p for p in new_path_dict if not new_path_dict[p]] + try: + encoding.writelines(path, self.SPYDER_PATH) + encoding.writelines(not_active_path, self.SPYDER_NOT_ACTIVE_PATH) + except EnvironmentError as e: + logger.error(str(e)) + CONF.set('main', 'spyder_pythonpath', self.get_spyder_pythonpath()) + + def get_spyder_pythonpath_dict(self): + """ + Return Spyder PYTHONPATH. + + The returned ordered dictionary has the paths as keys and the state + as values. The state is `True` for active and `False` for inactive. + + Example: + OrderedDict([('/some/path, True), ('/some/other/path, False)]) + """ + self.load_python_path() + + path_dict = OrderedDict() + for path in self.path: + path_dict[path] = path not in self.not_active_path + + for path in self.project_path: + path_dict[path] = True + + return path_dict + + def get_spyder_pythonpath(self): + """ + Return Spyder PYTHONPATH. + """ + path_dict = self.get_spyder_pythonpath_dict() + path = [k for k, v in path_dict.items() if v] + return path + + def update_python_path(self, new_path_dict): + """Update python path on Spyder interpreter and kernels.""" + # Load previous path + path_dict = self.get_spyder_pythonpath_dict() + + # Save path + if path_dict != new_path_dict: + # It doesn't include the project_path + self.save_python_path(new_path_dict) + + # Load new path + new_path_dict_p = self.get_spyder_pythonpath_dict() # Includes project + + # Any plugin that needs to do some work based on this signal should + # connect to it on plugin registration + self.sig_pythonpath_changed.emit(path_dict, new_path_dict_p) + + @Slot() + def show_path_manager(self): + """Show path manager dialog.""" + def _dialog_finished(result_code): + """Restore path manager dialog instance variable.""" + self._path_manager = None + + if self._path_manager is None: + from spyder.widgets.pathmanager import PathManager + projects = self.get_plugin(Plugins.Projects, error=False) + read_only_path = () + if projects: + read_only_path = tuple(projects.get_pythonpath()) + + dialog = PathManager(self, self.path, read_only_path, + self.not_active_path, sync=True) + self._path_manager = dialog + dialog.sig_path_changed.connect(self.update_python_path) + dialog.redirect_stdio.connect(self.redirect_internalshell_stdio) + dialog.finished.connect(_dialog_finished) + dialog.show() + else: + self._path_manager.show() + self._path_manager.activateWindow() + self._path_manager.raise_() + self._path_manager.setFocus() + + def pythonpath_changed(self): + """Project's PYTHONPATH contribution has changed.""" + projects = self.get_plugin(Plugins.Projects, error=False) + + self.project_path = () + if projects: + self.project_path = tuple(projects.get_pythonpath()) + path_dict = self.get_spyder_pythonpath_dict() + self.update_python_path(path_dict) + + #---- Preferences + def apply_settings(self): + """Apply main window settings.""" + qapp = QApplication.instance() + + # Set 'gtk+' as the default theme in Gtk-based desktops + # Fixes spyder-ide/spyder#2036. + if is_gtk_desktop() and ('GTK+' in QStyleFactory.keys()): + try: + qapp.setStyle('gtk+') + except: + pass + + default = self.DOCKOPTIONS + if CONF.get('main', 'vertical_tabs'): + default = default|QMainWindow.VerticalTabs + self.setDockOptions(default) + + self.apply_panes_settings() + + if CONF.get('main', 'use_custom_cursor_blinking'): + qapp.setCursorFlashTime( + CONF.get('main', 'custom_cursor_blinking')) + else: + qapp.setCursorFlashTime(self.CURSORBLINK_OSDEFAULT) + + def apply_panes_settings(self): + """Update dockwidgets features settings.""" + for plugin in (self.widgetlist + self.thirdparty_plugins): + features = plugin.dockwidget.FEATURES + + plugin.dockwidget.setFeatures(features) + + try: + # New API + margin = 0 + if CONF.get('main', 'use_custom_margin'): + margin = CONF.get('main', 'custom_margin') + plugin.update_margins(margin) + except AttributeError: + # Old API + plugin._update_margins() + + @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 + + # ---- Open files server + def start_open_files_server(self): + self.open_files_server.setsockopt(socket.SOL_SOCKET, + socket.SO_REUSEADDR, 1) + port = select_port(default_port=OPEN_FILES_PORT) + CONF.set('main', 'open_files_port', port) + + # This is necessary in case it's not possible to bind a port for the + # server in the system. + # Fixes spyder-ide/spyder#18262 + try: + self.open_files_server.bind(('127.0.0.1', port)) + except OSError: + self.open_files_server = None + return + + # Number of petitions the server can queue + self.open_files_server.listen(20) + + while 1: # 1 is faster than True + try: + req, dummy = self.open_files_server.accept() + except socket.error as e: + # See spyder-ide/spyder#1275 for details on why errno EINTR is + # silently ignored here. + eintr = errno.WSAEINTR if os.name == 'nt' else errno.EINTR + # To avoid a traceback after closing on Windows + if e.args[0] == eintr: + continue + # handle a connection abort on close error + enotsock = (errno.WSAENOTSOCK if os.name == 'nt' + else errno.ENOTSOCK) + if e.args[0] in [errno.ECONNABORTED, enotsock]: + return + if self.already_closed: + return + raise + fname = req.recv(1024) + fname = fname.decode('utf-8') + self.sig_open_external_file.emit(fname) + req.sendall(b' ') + + # ---- Quit and restart, and reset spyder defaults + @Slot() + def reset_spyder(self): + """ + Quit and reset Spyder and then Restart application. + """ + answer = QMessageBox.warning(self, _("Warning"), + _("Spyder will restart and reset to default settings:

" + "Do you want to continue?"), + QMessageBox.Yes | QMessageBox.No) + if answer == QMessageBox.Yes: + self.restart(reset=True) + + @Slot() + def restart(self, reset=False, close_immediately=False): + """Wrapper to handle plugins request to restart Spyder.""" + self.application.restart( + reset=reset, close_immediately=close_immediately) + + # ---- Global Switcher + def open_switcher(self, symbol=False): + """Open switcher dialog box.""" + if self.switcher is not None and self.switcher.isVisible(): + self.switcher.clear() + self.switcher.hide() + return + if symbol: + self.switcher.set_search_text('@') + else: + self.switcher.set_search_text('') + self.switcher.setup() + self.switcher.show() + + # Note: The +6 pixel on the top makes it look better + # FIXME: Why is this using the toolbars menu? A: To not be on top of + # the toolbars. + # Probably toolbars should be taken into account for this 'delta' only + # when are visible + delta_top = (self.toolbar.toolbars_menu.geometry().height() + + self.menuBar().geometry().height() + 6) + + self.switcher.set_position(delta_top) + + def open_symbolfinder(self): + """Open symbol list management dialog box.""" + self.open_switcher(symbol=True) + + def create_switcher(self): + """Create switcher dialog instance.""" + if self.switcher is None: + from spyder.widgets.switcher import Switcher + self.switcher = Switcher(self) + + return self.switcher + + # --- For OpenGL + def _test_setting_opengl(self, option): + """Get the current OpenGL implementation in use""" + if option == 'software': + return QCoreApplication.testAttribute(Qt.AA_UseSoftwareOpenGL) + elif option == 'desktop': + return QCoreApplication.testAttribute(Qt.AA_UseDesktopOpenGL) + elif option == 'gles': + return QCoreApplication.testAttribute(Qt.AA_UseOpenGLES) + + +#============================================================================== +# Main +#============================================================================== +def main(options, args): + """Main function""" + # **** For Pytest **** + if running_under_pytest(): + if CONF.get('main', 'opengl') != 'automatic': + option = CONF.get('main', 'opengl') + set_opengl_implementation(option) + + app = create_application() + window = create_window(MainWindow, app, None, options, None) + return window + + # **** Handle hide_console option **** + if options.show_console: + print("(Deprecated) --show console does nothing, now the default " + " behavior is to show the console, use --hide-console if you " + "want to hide it") + + if set_attached_console_visible is not None: + set_attached_console_visible(not options.hide_console + or options.reset_config_files + or options.reset_to_defaults + or options.optimize + or bool(get_debug_level())) + + # **** Set OpenGL implementation to use **** + # This attribute must be set before creating the application. + # See spyder-ide/spyder#11227 + if options.opengl_implementation: + option = options.opengl_implementation + set_opengl_implementation(option) + else: + if CONF.get('main', 'opengl') != 'automatic': + option = CONF.get('main', 'opengl') + set_opengl_implementation(option) + + # **** Set high DPI scaling **** + # This attribute must be set before creating the application. + if hasattr(Qt, 'AA_EnableHighDpiScaling'): + QCoreApplication.setAttribute(Qt.AA_EnableHighDpiScaling, + CONF.get('main', 'high_dpi_scaling')) + + # **** Set debugging info **** + if get_debug_level() > 0: + delete_debug_log_files() + setup_logging(options) + + # **** Create the application **** + app = create_application() + + # **** Create splash screen **** + splash = create_splash_screen() + if splash is not None: + splash.show() + splash.showMessage( + _("Initializing..."), + int(Qt.AlignBottom | Qt.AlignCenter | Qt.AlignAbsolute), + QColor(Qt.white) + ) + QApplication.processEvents() + + if options.reset_to_defaults: + # Reset Spyder settings to defaults + CONF.reset_to_defaults() + return + elif options.optimize: + # Optimize the whole Spyder's source code directory + import spyder + programs.run_python_script(module="compileall", + args=[spyder.__path__[0]], p_args=['-O']) + return + + # **** Read faulthandler log file **** + faulthandler_file = get_conf_path('faulthandler.log') + previous_crash = '' + if osp.exists(faulthandler_file): + with open(faulthandler_file, 'r') as f: + previous_crash = f.read() + + # Remove file to not pick it up for next time. + try: + dst = get_conf_path('faulthandler.log.old') + shutil.move(faulthandler_file, dst) + except Exception: + pass + CONF.set('main', 'previous_crash', previous_crash) + + # **** Set color for links **** + set_links_color(app) + + # **** Create main window **** + mainwindow = None + try: + if PY3 and options.report_segfault: + import faulthandler + with open(faulthandler_file, 'w') as f: + faulthandler.enable(file=f) + mainwindow = create_window( + MainWindow, app, splash, options, args + ) + else: + mainwindow = create_window(MainWindow, app, splash, options, args) + except FontError: + QMessageBox.information(None, "Spyder", + "Spyder was unable to load the Spyder 3 " + "icon theme. That's why it's going to fallback to the " + "theme used in Spyder 2.

" + "For that, please close this window and start Spyder again.") + CONF.set('appearance', 'icon_theme', 'spyder 2') + if mainwindow is None: + # An exception occurred + if splash is not None: + splash.hide() + return + + ORIGINAL_SYS_EXIT() + + +if __name__ == "__main__": + main() diff --git a/spyder/app/restart.py b/spyder/app/restart.py index 524e96b8ad8..b85dd03cb1e 100644 --- a/spyder/app/restart.py +++ b/spyder/app/restart.py @@ -1,301 +1,301 @@ -#!/usr/bin/env python -# -*- coding: utf-8 -*- -# -# Copyright © Spyder Project Contributors -# Licensed under the terms of the MIT License -# (see spyder/__init__.py for details) - -""" -Restart Spyder - -A helper script that allows to restart (and also reset) Spyder from within the -running application. -""" - -# Standard library imports -import ast -import os -import os.path as osp -import subprocess -import sys -import time - -# Third party imports -from qtpy.QtCore import Qt, QTimer -from qtpy.QtGui import QColor, QIcon -from qtpy.QtWidgets import QApplication, QMessageBox, QWidget - -# Local imports -from spyder.app.utils import create_splash_screen -from spyder.config.base import _, running_in_mac_app -from spyder.utils.image_path_manager import get_image_path -from spyder.utils.encoding import to_unicode -from spyder.utils.qthelpers import qapplication -from spyder.config.manager import CONF - - -PY2 = sys.version[0] == '2' -IS_WINDOWS = os.name == 'nt' -SLEEP_TIME = 0.2 # Seconds for throttling control -CLOSE_ERROR, RESET_ERROR, RESTART_ERROR = [1, 2, 3] # Spyder error codes - - -def _is_pid_running_on_windows(pid): - """Check if a process is running on windows systems based on the pid.""" - pid = str(pid) - - # Hide flashing command prompt - startupinfo = subprocess.STARTUPINFO() - startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW - - process = subprocess.Popen(r'tasklist /fi "PID eq {0}"'.format(pid), - stdout=subprocess.PIPE, - stderr=subprocess.STDOUT, - startupinfo=startupinfo) - stdoutdata, stderrdata = process.communicate() - stdoutdata = to_unicode(stdoutdata) - process.kill() - check = pid in stdoutdata - - return check - - -def _is_pid_running_on_unix(pid): - """Check if a process is running on unix systems based on the pid.""" - try: - # On unix systems os.kill with a 0 as second argument only pokes the - # process (if it exists) and does not kill it - os.kill(pid, 0) - except OSError: - return False - else: - return True - - -def is_pid_running(pid): - """Check if a process is running based on the pid.""" - # Select the correct function depending on the OS - if os.name == 'nt': - return _is_pid_running_on_windows(pid) - else: - return _is_pid_running_on_unix(pid) - - -class Restarter(QWidget): - """Widget in charge of displaying the splash information screen and the - error messages. - """ - - def __init__(self): - super(Restarter, self).__init__() - self.ellipsis = ['', '.', '..', '...', '..', '.'] - - # Widgets - self.timer_ellipsis = QTimer(self) - self.splash = create_splash_screen() - - # Widget setup - self.setVisible(False) - self.splash.show() - - self.timer_ellipsis.timeout.connect(self.animate_ellipsis) - - def _show_message(self, text): - """Show message on splash screen.""" - self.splash.showMessage(text, - int(Qt.AlignBottom | Qt.AlignCenter | - Qt.AlignAbsolute), - QColor(Qt.white)) - - def animate_ellipsis(self): - """Animate dots at the end of the splash screen message.""" - ellipsis = self.ellipsis.pop(0) - text = ' ' * len(ellipsis) + self.splash_text + ellipsis - self.ellipsis.append(ellipsis) - self._show_message(text) - - def set_splash_message(self, text): - """Sets the text in the bottom of the Splash screen.""" - self.splash_text = text - self._show_message(text) - self.timer_ellipsis.start(500) - - # Wait 1.2 seconds so we can give feedback to users that a - # restart is happening. - for __ in range(40): - time.sleep(0.03) - QApplication.processEvents() - - def launch_error_message(self, error_type, error=None): - """Launch a message box with a predefined error message. - - Parameters - ---------- - error_type : int [CLOSE_ERROR, RESET_ERROR, RESTART_ERROR] - Possible error codes when restarting/resetting spyder. - error : Exception - Actual Python exception error caught. - """ - messages = {CLOSE_ERROR: _("It was not possible to close the previous " - "Spyder instance.\nRestart aborted."), - RESET_ERROR: _("Spyder could not reset to factory " - "defaults.\nRestart aborted."), - RESTART_ERROR: _("It was not possible to restart Spyder.\n" - "Operation aborted.")} - titles = {CLOSE_ERROR: _("Spyder exit error"), - RESET_ERROR: _("Spyder reset error"), - RESTART_ERROR: _("Spyder restart error")} - - if error: - e = error.__repr__() - message = messages[error_type] + "\n\n{0}".format(e) - else: - message = messages[error_type] - - title = titles[error_type] - self.splash.hide() - QMessageBox.warning(self, title, message, QMessageBox.Ok) - raise RuntimeError(message) - - -def main(): - #========================================================================== - # Proper high DPI scaling is available in Qt >= 5.6.0. This attribute must - # be set before creating the application. - #========================================================================== - env = os.environ.copy() - - if CONF.get('main', 'high_dpi_custom_scale_factor'): - factors = str(CONF.get('main', 'high_dpi_custom_scale_factors')) - f = list(filter(None, factors.split(';'))) - if len(f) == 1: - env['QT_SCALE_FACTOR'] = f[0] - else: - env['QT_SCREEN_SCALE_FACTORS'] = factors - else: - env['QT_SCALE_FACTOR'] = '' - env['QT_SCREEN_SCALE_FACTORS'] = '' - - # Splash screen - # ------------------------------------------------------------------------- - # Start Qt Splash to inform the user of the current status - app = qapplication() - restarter = Restarter() - - APP_ICON = QIcon(get_image_path("spyder")) - app.setWindowIcon(APP_ICON) - restarter.set_splash_message(_('Closing Spyder')) - - # Get variables - spyder_args = env.pop('SPYDER_ARGS', None) - pid = env.pop('SPYDER_PID', None) - is_bootstrap = env.pop('SPYDER_IS_BOOTSTRAP', None) - reset = env.pop('SPYDER_RESET', 'False') - - # Get the spyder base folder based on this file - spyder_dir = osp.dirname(osp.dirname(osp.dirname(osp.abspath(__file__)))) - - if not any([spyder_args, pid, is_bootstrap, reset]): - error = "This script can only be called from within a Spyder instance" - raise RuntimeError(error) - - # Variables were stored as string literals in the environment, so to use - # them we need to parse them in a safe manner. - is_bootstrap = ast.literal_eval(is_bootstrap) - pid = ast.literal_eval(pid) - args = ast.literal_eval(spyder_args) - reset = ast.literal_eval(reset) - - # SPYDER_DEBUG takes presedence over SPYDER_ARGS - if '--debug' in args: - args.remove('--debug') - for level in ['minimal', 'verbose']: - arg = f'--debug-info={level}' - if arg in args: - args.remove(arg) - - # Enforce the --new-instance flag when running spyder - if '--new-instance' not in args: - if is_bootstrap and '--' not in args: - args = args + ['--', '--new-instance'] - else: - args.append('--new-instance') - - # Create the arguments needed for resetting - if '--' in args: - args_reset = ['--', '--reset'] - else: - args_reset = ['--reset'] - - # Build the base command - if running_in_mac_app(sys.executable): - exe = env['EXECUTABLEPATH'] - command = [f'"{exe}"'] - else: - if is_bootstrap: - script = osp.join(spyder_dir, 'bootstrap.py') - else: - script = osp.join(spyder_dir, 'spyder', 'app', 'start.py') - - command = [f'"{sys.executable}"', f'"{script}"'] - - # Adjust the command and/or arguments to subprocess depending on the OS - shell = not IS_WINDOWS - - # Before launching a new Spyder instance we need to make sure that the - # previous one has closed. We wait for a fixed and "reasonable" amount of - # time and check, otherwise an error is launched - wait_time = 90 if IS_WINDOWS else 30 # Seconds - for counter in range(int(wait_time / SLEEP_TIME)): - if not is_pid_running(pid): - break - time.sleep(SLEEP_TIME) # Throttling control - QApplication.processEvents() # Needed to refresh the splash - else: - # The old spyder instance took too long to close and restart aborts - restarter.launch_error_message(error_type=CLOSE_ERROR) - - # Reset Spyder (if required) - # ------------------------------------------------------------------------- - if reset: - restarter.set_splash_message(_('Resetting Spyder to defaults')) - - try: - p = subprocess.Popen(' '.join(command + args_reset), - shell=shell, env=env) - except Exception as error: - restarter.launch_error_message(error_type=RESET_ERROR, error=error) - else: - p.communicate() - pid_reset = p.pid - - # Before launching a new Spyder instance we need to make sure that the - # reset subprocess has closed. We wait for a fixed and "reasonable" - # amount of time and check, otherwise an error is launched. - wait_time = 20 # Seconds - for counter in range(int(wait_time / SLEEP_TIME)): - if not is_pid_running(pid_reset): - break - time.sleep(SLEEP_TIME) # Throttling control - QApplication.processEvents() # Needed to refresh the splash - else: - # The reset subprocess took too long and it is killed - try: - p.kill() - except OSError as error: - restarter.launch_error_message(error_type=RESET_ERROR, - error=error) - else: - restarter.launch_error_message(error_type=RESET_ERROR) - - # Restart - # ------------------------------------------------------------------------- - restarter.set_splash_message(_('Restarting')) - try: - subprocess.Popen(' '.join(command + args), shell=shell, env=env) - except Exception as error: - restarter.launch_error_message(error_type=RESTART_ERROR, error=error) - - -if __name__ == '__main__': - main() +#!/usr/bin/env python +# -*- coding: utf-8 -*- +# +# Copyright © Spyder Project Contributors +# Licensed under the terms of the MIT License +# (see spyder/__init__.py for details) + +""" +Restart Spyder + +A helper script that allows to restart (and also reset) Spyder from within the +running application. +""" + +# Standard library imports +import ast +import os +import os.path as osp +import subprocess +import sys +import time + +# Third party imports +from qtpy.QtCore import Qt, QTimer +from qtpy.QtGui import QColor, QIcon +from qtpy.QtWidgets import QApplication, QMessageBox, QWidget + +# Local imports +from spyder.app.utils import create_splash_screen +from spyder.config.base import _, running_in_mac_app +from spyder.utils.image_path_manager import get_image_path +from spyder.utils.encoding import to_unicode +from spyder.utils.qthelpers import qapplication +from spyder.config.manager import CONF + + +PY2 = sys.version[0] == '2' +IS_WINDOWS = os.name == 'nt' +SLEEP_TIME = 0.2 # Seconds for throttling control +CLOSE_ERROR, RESET_ERROR, RESTART_ERROR = [1, 2, 3] # Spyder error codes + + +def _is_pid_running_on_windows(pid): + """Check if a process is running on windows systems based on the pid.""" + pid = str(pid) + + # Hide flashing command prompt + startupinfo = subprocess.STARTUPINFO() + startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW + + process = subprocess.Popen(r'tasklist /fi "PID eq {0}"'.format(pid), + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + startupinfo=startupinfo) + stdoutdata, stderrdata = process.communicate() + stdoutdata = to_unicode(stdoutdata) + process.kill() + check = pid in stdoutdata + + return check + + +def _is_pid_running_on_unix(pid): + """Check if a process is running on unix systems based on the pid.""" + try: + # On unix systems os.kill with a 0 as second argument only pokes the + # process (if it exists) and does not kill it + os.kill(pid, 0) + except OSError: + return False + else: + return True + + +def is_pid_running(pid): + """Check if a process is running based on the pid.""" + # Select the correct function depending on the OS + if os.name == 'nt': + return _is_pid_running_on_windows(pid) + else: + return _is_pid_running_on_unix(pid) + + +class Restarter(QWidget): + """Widget in charge of displaying the splash information screen and the + error messages. + """ + + def __init__(self): + super(Restarter, self).__init__() + self.ellipsis = ['', '.', '..', '...', '..', '.'] + + # Widgets + self.timer_ellipsis = QTimer(self) + self.splash = create_splash_screen() + + # Widget setup + self.setVisible(False) + self.splash.show() + + self.timer_ellipsis.timeout.connect(self.animate_ellipsis) + + def _show_message(self, text): + """Show message on splash screen.""" + self.splash.showMessage(text, + int(Qt.AlignBottom | Qt.AlignCenter | + Qt.AlignAbsolute), + QColor(Qt.white)) + + def animate_ellipsis(self): + """Animate dots at the end of the splash screen message.""" + ellipsis = self.ellipsis.pop(0) + text = ' ' * len(ellipsis) + self.splash_text + ellipsis + self.ellipsis.append(ellipsis) + self._show_message(text) + + def set_splash_message(self, text): + """Sets the text in the bottom of the Splash screen.""" + self.splash_text = text + self._show_message(text) + self.timer_ellipsis.start(500) + + # Wait 1.2 seconds so we can give feedback to users that a + # restart is happening. + for __ in range(40): + time.sleep(0.03) + QApplication.processEvents() + + def launch_error_message(self, error_type, error=None): + """Launch a message box with a predefined error message. + + Parameters + ---------- + error_type : int [CLOSE_ERROR, RESET_ERROR, RESTART_ERROR] + Possible error codes when restarting/resetting spyder. + error : Exception + Actual Python exception error caught. + """ + messages = {CLOSE_ERROR: _("It was not possible to close the previous " + "Spyder instance.\nRestart aborted."), + RESET_ERROR: _("Spyder could not reset to factory " + "defaults.\nRestart aborted."), + RESTART_ERROR: _("It was not possible to restart Spyder.\n" + "Operation aborted.")} + titles = {CLOSE_ERROR: _("Spyder exit error"), + RESET_ERROR: _("Spyder reset error"), + RESTART_ERROR: _("Spyder restart error")} + + if error: + e = error.__repr__() + message = messages[error_type] + "\n\n{0}".format(e) + else: + message = messages[error_type] + + title = titles[error_type] + self.splash.hide() + QMessageBox.warning(self, title, message, QMessageBox.Ok) + raise RuntimeError(message) + + +def main(): + #========================================================================== + # Proper high DPI scaling is available in Qt >= 5.6.0. This attribute must + # be set before creating the application. + #========================================================================== + env = os.environ.copy() + + if CONF.get('main', 'high_dpi_custom_scale_factor'): + factors = str(CONF.get('main', 'high_dpi_custom_scale_factors')) + f = list(filter(None, factors.split(';'))) + if len(f) == 1: + env['QT_SCALE_FACTOR'] = f[0] + else: + env['QT_SCREEN_SCALE_FACTORS'] = factors + else: + env['QT_SCALE_FACTOR'] = '' + env['QT_SCREEN_SCALE_FACTORS'] = '' + + # Splash screen + # ------------------------------------------------------------------------- + # Start Qt Splash to inform the user of the current status + app = qapplication() + restarter = Restarter() + + APP_ICON = QIcon(get_image_path("spyder")) + app.setWindowIcon(APP_ICON) + restarter.set_splash_message(_('Closing Spyder')) + + # Get variables + spyder_args = env.pop('SPYDER_ARGS', None) + pid = env.pop('SPYDER_PID', None) + is_bootstrap = env.pop('SPYDER_IS_BOOTSTRAP', None) + reset = env.pop('SPYDER_RESET', 'False') + + # Get the spyder base folder based on this file + spyder_dir = osp.dirname(osp.dirname(osp.dirname(osp.abspath(__file__)))) + + if not any([spyder_args, pid, is_bootstrap, reset]): + error = "This script can only be called from within a Spyder instance" + raise RuntimeError(error) + + # Variables were stored as string literals in the environment, so to use + # them we need to parse them in a safe manner. + is_bootstrap = ast.literal_eval(is_bootstrap) + pid = ast.literal_eval(pid) + args = ast.literal_eval(spyder_args) + reset = ast.literal_eval(reset) + + # SPYDER_DEBUG takes presedence over SPYDER_ARGS + if '--debug' in args: + args.remove('--debug') + for level in ['minimal', 'verbose']: + arg = f'--debug-info={level}' + if arg in args: + args.remove(arg) + + # Enforce the --new-instance flag when running spyder + if '--new-instance' not in args: + if is_bootstrap and '--' not in args: + args = args + ['--', '--new-instance'] + else: + args.append('--new-instance') + + # Create the arguments needed for resetting + if '--' in args: + args_reset = ['--', '--reset'] + else: + args_reset = ['--reset'] + + # Build the base command + if running_in_mac_app(sys.executable): + exe = env['EXECUTABLEPATH'] + command = [f'"{exe}"'] + else: + if is_bootstrap: + script = osp.join(spyder_dir, 'bootstrap.py') + else: + script = osp.join(spyder_dir, 'spyder', 'app', 'start.py') + + command = [f'"{sys.executable}"', f'"{script}"'] + + # Adjust the command and/or arguments to subprocess depending on the OS + shell = not IS_WINDOWS + + # Before launching a new Spyder instance we need to make sure that the + # previous one has closed. We wait for a fixed and "reasonable" amount of + # time and check, otherwise an error is launched + wait_time = 90 if IS_WINDOWS else 30 # Seconds + for counter in range(int(wait_time / SLEEP_TIME)): + if not is_pid_running(pid): + break + time.sleep(SLEEP_TIME) # Throttling control + QApplication.processEvents() # Needed to refresh the splash + else: + # The old spyder instance took too long to close and restart aborts + restarter.launch_error_message(error_type=CLOSE_ERROR) + + # Reset Spyder (if required) + # ------------------------------------------------------------------------- + if reset: + restarter.set_splash_message(_('Resetting Spyder to defaults')) + + try: + p = subprocess.Popen(' '.join(command + args_reset), + shell=shell, env=env) + except Exception as error: + restarter.launch_error_message(error_type=RESET_ERROR, error=error) + else: + p.communicate() + pid_reset = p.pid + + # Before launching a new Spyder instance we need to make sure that the + # reset subprocess has closed. We wait for a fixed and "reasonable" + # amount of time and check, otherwise an error is launched. + wait_time = 20 # Seconds + for counter in range(int(wait_time / SLEEP_TIME)): + if not is_pid_running(pid_reset): + break + time.sleep(SLEEP_TIME) # Throttling control + QApplication.processEvents() # Needed to refresh the splash + else: + # The reset subprocess took too long and it is killed + try: + p.kill() + except OSError as error: + restarter.launch_error_message(error_type=RESET_ERROR, + error=error) + else: + restarter.launch_error_message(error_type=RESET_ERROR) + + # Restart + # ------------------------------------------------------------------------- + restarter.set_splash_message(_('Restarting')) + try: + subprocess.Popen(' '.join(command + args), shell=shell, env=env) + except Exception as error: + restarter.launch_error_message(error_type=RESTART_ERROR, error=error) + + +if __name__ == '__main__': + main() diff --git a/spyder/app/start.py b/spyder/app/start.py index c4e8771859d..e1286162f96 100644 --- a/spyder/app/start.py +++ b/spyder/app/start.py @@ -1,270 +1,270 @@ -# -*- coding: utf-8 -*- -# ----------------------------------------------------------------------------- -# Copyright (c) 2009- Spyder Project Contributors -# -# Distributed under the terms of the MIT License -# (see spyder/__init__.py for details) -# ----------------------------------------------------------------------------- - -# Remove PYTHONPATH paths from sys.path before other imports to protect against -# shadowed standard libraries. -import os -import sys -if os.environ.get('PYTHONPATH'): - for path in os.environ['PYTHONPATH'].split(os.pathsep): - try: - sys.path.remove(path.rstrip(os.sep)) - except ValueError: - pass - -# Standard library imports -import ctypes -import logging -import os.path as osp -import random -import socket -import time - -# Prevent showing internal logging errors -# Fixes spyder-ide/spyder#15768 -logging.raiseExceptions = False - -# Prevent that our dependencies display warnings when not in debug mode. -# Some of them are reported in the console and others through our -# report error dialog. -# Note: The log level when debugging is set on the main window. -# Fixes spyder-ide/spyder#15163 -root_logger = logging.getLogger() -root_logger.setLevel(logging.ERROR) - -# Prevent a race condition with ZMQ -# See spyder-ide/spyder#5324. -import zmq - -# Load GL library to prevent segmentation faults on some Linux systems -# See spyder-ide/spyder#3226 and spyder-ide/spyder#3332. -try: - ctypes.CDLL("libGL.so.1", mode=ctypes.RTLD_GLOBAL) -except: - pass - -# Local imports -from spyder.app.cli_options import get_options -from spyder.config.base import (get_conf_path, running_in_mac_app, - reset_config_files, running_under_pytest) -from spyder.utils.external import lockfile -from spyder.py3compat import is_unicode - - -# Get argv -if running_under_pytest(): - sys_argv = [sys.argv[0]] - CLI_OPTIONS, CLI_ARGS = get_options(sys_argv) -else: - CLI_OPTIONS, CLI_ARGS = get_options() - -# Start Spyder with a clean configuration directory for testing purposes -if CLI_OPTIONS.safe_mode: - os.environ['SPYDER_SAFE_MODE'] = 'True' - -if CLI_OPTIONS.conf_dir: - os.environ['SPYDER_CONFDIR'] = CLI_OPTIONS.conf_dir - - -def send_args_to_spyder(args): - """ - Simple socket client used to send the args passed to the Spyder - executable to an already running instance. - - Args can be Python scripts or files with these extensions: .spydata, .mat, - .npy, or .h5, which can be imported by the Variable Explorer. - """ - from spyder.config.manager import CONF - port = CONF.get('main', 'open_files_port') - print_warning = True - - # Wait ~50 secs for the server to be up - # Taken from https://stackoverflow.com/a/4766598/438386 - for __ in range(200): - try: - for arg in args: - client = socket.socket(socket.AF_INET, socket.SOCK_STREAM, - socket.IPPROTO_TCP) - client.connect(("127.0.0.1", port)) - if is_unicode(arg): - arg = arg.encode('utf-8') - client.send(osp.abspath(arg)) - client.close() - except socket.error: - # Print informative warning to let users know what Spyder is doing - if print_warning: - print("Waiting for the server to open files and directories " - "to be up (perhaps it failed to be started).") - print_warning = False - - # Wait 250 ms before trying again - time.sleep(0.25) - continue - break - - -def main(): - """ - Start Spyder application. - - If single instance mode is turned on (default behavior) and an instance of - Spyder is already running, this will just parse and send command line - options to the application. - """ - # Parse command line options - options, args = (CLI_OPTIONS, CLI_ARGS) - - # This is to allow reset without reading our conf file - if options.reset_config_files: - # Remove all configuration files! - reset_config_files() - return - - from spyder.config.manager import CONF - - # Store variable to be used in self.restart (restart spyder instance) - os.environ['SPYDER_ARGS'] = str(sys.argv[1:]) - - #========================================================================== - # Proper high DPI scaling is available in Qt >= 5.6.0. This attribute must - # be set before creating the application. - #========================================================================== - if CONF.get('main', 'high_dpi_custom_scale_factor'): - factors = str(CONF.get('main', 'high_dpi_custom_scale_factors')) - f = list(filter(None, factors.split(';'))) - if len(f) == 1: - os.environ['QT_SCALE_FACTOR'] = f[0] - else: - os.environ['QT_SCREEN_SCALE_FACTORS'] = factors - else: - os.environ['QT_SCALE_FACTOR'] = '' - os.environ['QT_SCREEN_SCALE_FACTORS'] = '' - - if sys.platform == 'darwin': - # Fixes launching issues with Big Sur (spyder-ide/spyder#14222) - os.environ['QT_MAC_WANTS_LAYER'] = '1' - # Prevent Spyder from crashing in macOS if locale is not defined - LANG = os.environ.get('LANG') - LC_ALL = os.environ.get('LC_ALL') - if bool(LANG) and not bool(LC_ALL): - LC_ALL = LANG - elif not bool(LANG) and bool(LC_ALL): - LANG = LC_ALL - else: - LANG = LC_ALL = 'en_US.UTF-8' - - os.environ['LANG'] = LANG - os.environ['LC_ALL'] = LC_ALL - - # Don't show useless warning in the terminal where Spyder - # was started. - # See spyder-ide/spyder#3730. - os.environ['EVENT_NOKQUEUE'] = '1' - else: - # Prevent our kernels to crash when Python fails to identify - # the system locale. - # Fixes spyder-ide/spyder#7051. - try: - from locale import getlocale - getlocale() - except ValueError: - # This can fail on Windows. See spyder-ide/spyder#6886. - try: - os.environ['LANG'] = 'C' - os.environ['LC_ALL'] = 'C' - except Exception: - pass - - if options.debug_info: - levels = {'minimal': '2', 'verbose': '3'} - os.environ['SPYDER_DEBUG'] = levels[options.debug_info] - - _filename = 'spyder-debug.log' - if options.debug_output == 'file': - _filepath = osp.realpath(_filename) - else: - _filepath = get_conf_path(_filename) - os.environ['SPYDER_DEBUG_FILE'] = _filepath - - if options.paths: - from spyder.config.base import get_conf_paths - sys.stdout.write('\nconfig:' + '\n') - for path in reversed(get_conf_paths()): - sys.stdout.write('\t' + path + '\n') - sys.stdout.write('\n' ) - return - - if (CONF.get('main', 'single_instance') and not options.new_instance - and not options.reset_config_files - and not running_in_mac_app()): - # Minimal delay (0.1-0.2 secs) to avoid that several - # instances started at the same time step in their - # own foots while trying to create the lock file - time.sleep(random.randrange(1000, 2000, 90)/10000.) - - # Lock file creation - lock_file = get_conf_path('spyder.lock') - lock = lockfile.FilesystemLock(lock_file) - - # Try to lock spyder.lock. If it's *possible* to do it, then - # there is no previous instance running and we can start a - # new one. If *not*, then there is an instance already - # running, which is locking that file - try: - lock_created = lock.lock() - except: - # If locking fails because of errors in the lockfile - # module, try to remove a possibly stale spyder.lock. - # This is reported to solve all problems with lockfile. - # See spyder-ide/spyder#2363. - try: - if os.name == 'nt': - if osp.isdir(lock_file): - import shutil - shutil.rmtree(lock_file, ignore_errors=True) - else: - if osp.islink(lock_file): - os.unlink(lock_file) - except: - pass - - # Then start Spyder as usual and *don't* continue - # executing this script because it doesn't make - # sense - from spyder.app import mainwindow - if running_under_pytest(): - return mainwindow.main(options, args) - else: - mainwindow.main(options, args) - return - - if lock_created: - # Start a new instance - from spyder.app import mainwindow - if running_under_pytest(): - return mainwindow.main(options, args) - else: - mainwindow.main(options, args) - else: - # Pass args to Spyder or print an informative - # message - if args: - send_args_to_spyder(args) - else: - print("Spyder is already running. If you want to open a new \n" - "instance, please use the --new-instance option") - else: - from spyder.app import mainwindow - if running_under_pytest(): - return mainwindow.main(options, args) - else: - mainwindow.main(options, args) - - -if __name__ == "__main__": - main() +# -*- coding: utf-8 -*- +# ----------------------------------------------------------------------------- +# Copyright (c) 2009- Spyder Project Contributors +# +# Distributed under the terms of the MIT License +# (see spyder/__init__.py for details) +# ----------------------------------------------------------------------------- + +# Remove PYTHONPATH paths from sys.path before other imports to protect against +# shadowed standard libraries. +import os +import sys +if os.environ.get('PYTHONPATH'): + for path in os.environ['PYTHONPATH'].split(os.pathsep): + try: + sys.path.remove(path.rstrip(os.sep)) + except ValueError: + pass + +# Standard library imports +import ctypes +import logging +import os.path as osp +import random +import socket +import time + +# Prevent showing internal logging errors +# Fixes spyder-ide/spyder#15768 +logging.raiseExceptions = False + +# Prevent that our dependencies display warnings when not in debug mode. +# Some of them are reported in the console and others through our +# report error dialog. +# Note: The log level when debugging is set on the main window. +# Fixes spyder-ide/spyder#15163 +root_logger = logging.getLogger() +root_logger.setLevel(logging.ERROR) + +# Prevent a race condition with ZMQ +# See spyder-ide/spyder#5324. +import zmq + +# Load GL library to prevent segmentation faults on some Linux systems +# See spyder-ide/spyder#3226 and spyder-ide/spyder#3332. +try: + ctypes.CDLL("libGL.so.1", mode=ctypes.RTLD_GLOBAL) +except: + pass + +# Local imports +from spyder.app.cli_options import get_options +from spyder.config.base import (get_conf_path, running_in_mac_app, + reset_config_files, running_under_pytest) +from spyder.utils.external import lockfile +from spyder.py3compat import is_unicode + + +# Get argv +if running_under_pytest(): + sys_argv = [sys.argv[0]] + CLI_OPTIONS, CLI_ARGS = get_options(sys_argv) +else: + CLI_OPTIONS, CLI_ARGS = get_options() + +# Start Spyder with a clean configuration directory for testing purposes +if CLI_OPTIONS.safe_mode: + os.environ['SPYDER_SAFE_MODE'] = 'True' + +if CLI_OPTIONS.conf_dir: + os.environ['SPYDER_CONFDIR'] = CLI_OPTIONS.conf_dir + + +def send_args_to_spyder(args): + """ + Simple socket client used to send the args passed to the Spyder + executable to an already running instance. + + Args can be Python scripts or files with these extensions: .spydata, .mat, + .npy, or .h5, which can be imported by the Variable Explorer. + """ + from spyder.config.manager import CONF + port = CONF.get('main', 'open_files_port') + print_warning = True + + # Wait ~50 secs for the server to be up + # Taken from https://stackoverflow.com/a/4766598/438386 + for __ in range(200): + try: + for arg in args: + client = socket.socket(socket.AF_INET, socket.SOCK_STREAM, + socket.IPPROTO_TCP) + client.connect(("127.0.0.1", port)) + if is_unicode(arg): + arg = arg.encode('utf-8') + client.send(osp.abspath(arg)) + client.close() + except socket.error: + # Print informative warning to let users know what Spyder is doing + if print_warning: + print("Waiting for the server to open files and directories " + "to be up (perhaps it failed to be started).") + print_warning = False + + # Wait 250 ms before trying again + time.sleep(0.25) + continue + break + + +def main(): + """ + Start Spyder application. + + If single instance mode is turned on (default behavior) and an instance of + Spyder is already running, this will just parse and send command line + options to the application. + """ + # Parse command line options + options, args = (CLI_OPTIONS, CLI_ARGS) + + # This is to allow reset without reading our conf file + if options.reset_config_files: + # Remove all configuration files! + reset_config_files() + return + + from spyder.config.manager import CONF + + # Store variable to be used in self.restart (restart spyder instance) + os.environ['SPYDER_ARGS'] = str(sys.argv[1:]) + + #========================================================================== + # Proper high DPI scaling is available in Qt >= 5.6.0. This attribute must + # be set before creating the application. + #========================================================================== + if CONF.get('main', 'high_dpi_custom_scale_factor'): + factors = str(CONF.get('main', 'high_dpi_custom_scale_factors')) + f = list(filter(None, factors.split(';'))) + if len(f) == 1: + os.environ['QT_SCALE_FACTOR'] = f[0] + else: + os.environ['QT_SCREEN_SCALE_FACTORS'] = factors + else: + os.environ['QT_SCALE_FACTOR'] = '' + os.environ['QT_SCREEN_SCALE_FACTORS'] = '' + + if sys.platform == 'darwin': + # Fixes launching issues with Big Sur (spyder-ide/spyder#14222) + os.environ['QT_MAC_WANTS_LAYER'] = '1' + # Prevent Spyder from crashing in macOS if locale is not defined + LANG = os.environ.get('LANG') + LC_ALL = os.environ.get('LC_ALL') + if bool(LANG) and not bool(LC_ALL): + LC_ALL = LANG + elif not bool(LANG) and bool(LC_ALL): + LANG = LC_ALL + else: + LANG = LC_ALL = 'en_US.UTF-8' + + os.environ['LANG'] = LANG + os.environ['LC_ALL'] = LC_ALL + + # Don't show useless warning in the terminal where Spyder + # was started. + # See spyder-ide/spyder#3730. + os.environ['EVENT_NOKQUEUE'] = '1' + else: + # Prevent our kernels to crash when Python fails to identify + # the system locale. + # Fixes spyder-ide/spyder#7051. + try: + from locale import getlocale + getlocale() + except ValueError: + # This can fail on Windows. See spyder-ide/spyder#6886. + try: + os.environ['LANG'] = 'C' + os.environ['LC_ALL'] = 'C' + except Exception: + pass + + if options.debug_info: + levels = {'minimal': '2', 'verbose': '3'} + os.environ['SPYDER_DEBUG'] = levels[options.debug_info] + + _filename = 'spyder-debug.log' + if options.debug_output == 'file': + _filepath = osp.realpath(_filename) + else: + _filepath = get_conf_path(_filename) + os.environ['SPYDER_DEBUG_FILE'] = _filepath + + if options.paths: + from spyder.config.base import get_conf_paths + sys.stdout.write('\nconfig:' + '\n') + for path in reversed(get_conf_paths()): + sys.stdout.write('\t' + path + '\n') + sys.stdout.write('\n' ) + return + + if (CONF.get('main', 'single_instance') and not options.new_instance + and not options.reset_config_files + and not running_in_mac_app()): + # Minimal delay (0.1-0.2 secs) to avoid that several + # instances started at the same time step in their + # own foots while trying to create the lock file + time.sleep(random.randrange(1000, 2000, 90)/10000.) + + # Lock file creation + lock_file = get_conf_path('spyder.lock') + lock = lockfile.FilesystemLock(lock_file) + + # Try to lock spyder.lock. If it's *possible* to do it, then + # there is no previous instance running and we can start a + # new one. If *not*, then there is an instance already + # running, which is locking that file + try: + lock_created = lock.lock() + except: + # If locking fails because of errors in the lockfile + # module, try to remove a possibly stale spyder.lock. + # This is reported to solve all problems with lockfile. + # See spyder-ide/spyder#2363. + try: + if os.name == 'nt': + if osp.isdir(lock_file): + import shutil + shutil.rmtree(lock_file, ignore_errors=True) + else: + if osp.islink(lock_file): + os.unlink(lock_file) + except: + pass + + # Then start Spyder as usual and *don't* continue + # executing this script because it doesn't make + # sense + from spyder.app import mainwindow + if running_under_pytest(): + return mainwindow.main(options, args) + else: + mainwindow.main(options, args) + return + + if lock_created: + # Start a new instance + from spyder.app import mainwindow + if running_under_pytest(): + return mainwindow.main(options, args) + else: + mainwindow.main(options, args) + else: + # Pass args to Spyder or print an informative + # message + if args: + send_args_to_spyder(args) + else: + print("Spyder is already running. If you want to open a new \n" + "instance, please use the --new-instance option") + else: + from spyder.app import mainwindow + if running_under_pytest(): + return mainwindow.main(options, args) + else: + mainwindow.main(options, args) + + +if __name__ == "__main__": + main() diff --git a/spyder/config/base.py b/spyder/config/base.py index ab4d1b865f5..b247916e2e8 100644 --- a/spyder/config/base.py +++ b/spyder/config/base.py @@ -1,630 +1,630 @@ -# -*- coding: utf-8 -*- -# -# Copyright © Spyder Project Contributors -# Licensed under the terms of the MIT License -# (see spyder/__init__.py for details) - -""" -Spyder base configuration management - -This file only deals with non-GUI configuration features -(in other words, we won't import any PyQt object here, avoiding any -sip API incompatibility issue in spyder's non-gui modules) -""" - -import codecs -import locale -import os -import os.path as osp -import re -import shutil -import sys -import tempfile -import uuid -import warnings - -# Local imports -from spyder import __version__ -from spyder.py3compat import is_unicode, PY3, to_text_string, is_text_string -from spyder.utils import encoding - -#============================================================================== -# Only for development -#============================================================================== -# To activate/deactivate certain things for development -# SPYDER_DEV is (and *only* has to be) set in bootstrap.py -DEV = os.environ.get('SPYDER_DEV') - -# Manually override whether the dev configuration directory is used. -USE_DEV_CONFIG_DIR = os.environ.get('SPYDER_USE_DEV_CONFIG_DIR') - -# Get a random id for the safe-mode config dir -CLEAN_DIR_ID = str(uuid.uuid4()).split('-')[-1] - - -def get_safe_mode(): - """ - Make Spyder use a temp clean configuration directory for testing - purposes SPYDER_SAFE_MODE can be set using the --safe-mode option. - """ - return bool(os.environ.get('SPYDER_SAFE_MODE')) - - -def running_under_pytest(): - """ - Return True if currently running under pytest. - - This function is used to do some adjustment for testing. The environment - variable SPYDER_PYTEST is defined in conftest.py. - """ - return bool(os.environ.get('SPYDER_PYTEST')) - - -def running_in_ci(): - """Return True if currently running under CI.""" - return bool(os.environ.get('CI')) - - -def running_in_ci_with_conda(): - """Return True if currently running under CI with conda packages.""" - return running_in_ci() and bool(os.environ.get('USE_CONDA')) - - -def is_stable_version(version): - """ - Return true if version is stable, i.e. with letters in the final component. - - Stable version examples: ``1.2``, ``1.3.4``, ``1.0.5``. - Non-stable version examples: ``1.3.4beta``, ``0.1.0rc1``, ``3.0.0dev0``. - """ - if not isinstance(version, tuple): - version = version.split('.') - last_part = version[-1] - - if not re.search(r'[a-zA-Z]', last_part): - return True - else: - return False - - -def use_dev_config_dir(use_dev_config_dir=USE_DEV_CONFIG_DIR): - """Return whether the dev configuration directory should used.""" - if use_dev_config_dir is not None: - if use_dev_config_dir.lower() in {'false', '0'}: - use_dev_config_dir = False - else: - use_dev_config_dir = DEV or not is_stable_version(__version__) - - return use_dev_config_dir - - -#============================================================================== -# Debug helpers -#============================================================================== -# This is needed after restarting and using debug_print -STDOUT = sys.stdout if PY3 else codecs.getwriter('utf-8')(sys.stdout) -STDERR = sys.stderr - - -def get_debug_level(): - debug_env = os.environ.get('SPYDER_DEBUG', '') - if not debug_env.isdigit(): - debug_env = bool(debug_env) - return int(debug_env) - - -def debug_print(*message): - """Output debug messages to stdout""" - warnings.warn("debug_print is deprecated; use the logging module instead.") - if get_debug_level(): - ss = STDOUT - if PY3: - # This is needed after restarting and using debug_print - for m in message: - ss.buffer.write(str(m).encode('utf-8')) - print('', file=ss) - else: - print(*message, file=ss) - - -#============================================================================== -# Configuration paths -#============================================================================== -def get_conf_subfolder(): - """Return the configuration subfolder for different ooperating systems.""" - # Spyder settings dir - # NOTE: During the 2.x.x series this dir was named .spyder2, but - # since 3.0+ we've reverted back to use .spyder to simplify major - # updates in version (required when we change APIs by Linux - # packagers) - if sys.platform.startswith('linux'): - SUBFOLDER = 'spyder' - else: - SUBFOLDER = '.spyder' - - # We can't have PY2 and PY3 settings in the same dir because: - # 1. This leads to ugly crashes and freezes (e.g. by trying to - # embed a PY2 interpreter in PY3) - # 2. We need to save the list of installed modules (for code - # completion) separately for each version - if PY3: - SUBFOLDER = SUBFOLDER + '-py3' - - # If running a development/beta version, save config in a separate - # directory to avoid wiping or contaiminating the user's saved stable - # configuration. - if use_dev_config_dir(): - SUBFOLDER = SUBFOLDER + '-dev' - - return SUBFOLDER - - -def get_project_config_folder(): - """Return the default project configuration folder.""" - return '.spyproject' - - -def get_home_dir(): - """Return user home directory.""" - try: - # expanduser() returns a raw byte string which needs to be - # decoded with the codec that the OS is using to represent - # file paths. - path = encoding.to_unicode_from_fs(osp.expanduser('~')) - except Exception: - path = '' - - if osp.isdir(path): - return path - else: - # Get home from alternative locations - for env_var in ('HOME', 'USERPROFILE', 'TMP'): - # os.environ.get() returns a raw byte string which needs to be - # decoded with the codec that the OS is using to represent - # environment variables. - path = encoding.to_unicode_from_fs(os.environ.get(env_var, '')) - if osp.isdir(path): - return path - else: - path = '' - - if not path: - raise RuntimeError('Please set the environment variable HOME to ' - 'your user/home directory path so Spyder can ' - 'start properly.') - - -def get_clean_conf_dir(): - """ - Return the path to a temp clean configuration dir, for tests and safe mode. - """ - conf_dir = osp.join( - tempfile.gettempdir(), - 'spyder-clean-conf-dirs', - CLEAN_DIR_ID, - ) - return conf_dir - - -def get_custom_conf_dir(): - """ - Use a custom configuration directory, passed through our command - line options or by setting the env var below. - """ - custom_dir = os.environ.get('SPYDER_CONFDIR') - if custom_dir: - custom_dir = osp.abspath(custom_dir) - - # Set env var to not lose its value in future calls when the cwd - # is changed by Spyder. - os.environ['SPYDER_CONFDIR'] = custom_dir - return custom_dir - - -def get_conf_path(filename=None): - """Return absolute path to the config file with the specified filename.""" - # Define conf_dir - if running_under_pytest() or get_safe_mode(): - # Use clean config dir if running tests or the user requests it. - conf_dir = get_clean_conf_dir() - elif get_custom_conf_dir(): - # Use a custom directory if the user decided to do it through - # our command line options. - conf_dir = get_custom_conf_dir() - elif sys.platform.startswith('linux'): - # This makes us follow the XDG standard to save our settings - # on Linux, as it was requested on spyder-ide/spyder#2629. - xdg_config_home = os.environ.get('XDG_CONFIG_HOME', '') - if not xdg_config_home: - xdg_config_home = osp.join(get_home_dir(), '.config') - - if not osp.isdir(xdg_config_home): - os.makedirs(xdg_config_home) - - conf_dir = osp.join(xdg_config_home, get_conf_subfolder()) - else: - conf_dir = osp.join(get_home_dir(), get_conf_subfolder()) - - # Create conf_dir - if not osp.isdir(conf_dir): - if running_under_pytest() or get_safe_mode() or get_custom_conf_dir(): - os.makedirs(conf_dir) - else: - os.mkdir(conf_dir) - - if filename is None: - return conf_dir - else: - return osp.join(conf_dir, filename) - - -def get_conf_paths(): - """Return the files that can update system configuration defaults.""" - CONDA_PREFIX = os.environ.get('CONDA_PREFIX', None) - - if os.name == 'nt': - SEARCH_PATH = ( - 'C:/ProgramData/spyder', - ) - else: - SEARCH_PATH = ( - '/etc/spyder', - '/usr/local/etc/spyder', - ) - - if CONDA_PREFIX is not None: - CONDA_PREFIX = CONDA_PREFIX.replace('\\', '/') - SEARCH_PATH += ( - '{}/etc/spyder'.format(CONDA_PREFIX), - ) - - SEARCH_PATH += ( - '{}/etc/spyder'.format(sys.prefix), - ) - - if running_under_pytest(): - search_paths = [] - tmpfolder = str(tempfile.gettempdir()) - for i in range(3): - path = os.path.join(tmpfolder, 'site-config-' + str(i)) - if not os.path.isdir(path): - os.makedirs(path) - search_paths.append(path) - SEARCH_PATH = tuple(search_paths) - - return SEARCH_PATH - - -def get_module_path(modname): - """Return module *modname* base path""" - return osp.abspath(osp.dirname(sys.modules[modname].__file__)) - - -def get_module_data_path(modname, relpath=None, attr_name='DATAPATH'): - """Return module *modname* data path - Note: relpath is ignored if module has an attribute named *attr_name* - - Handles py2exe/cx_Freeze distributions""" - datapath = getattr(sys.modules[modname], attr_name, '') - if datapath: - return datapath - else: - datapath = get_module_path(modname) - parentdir = osp.join(datapath, osp.pardir) - if osp.isfile(parentdir): - # Parent directory is not a directory but the 'library.zip' file: - # this is either a py2exe or a cx_Freeze distribution - datapath = osp.abspath(osp.join(osp.join(parentdir, osp.pardir), - modname)) - if relpath is not None: - datapath = osp.abspath(osp.join(datapath, relpath)) - return datapath - - -def get_module_source_path(modname, basename=None): - """Return module *modname* source path - If *basename* is specified, return *modname.basename* path where - *modname* is a package containing the module *basename* - - *basename* is a filename (not a module name), so it must include the - file extension: .py or .pyw - - Handles py2exe/cx_Freeze distributions""" - srcpath = get_module_path(modname) - parentdir = osp.join(srcpath, osp.pardir) - if osp.isfile(parentdir): - # Parent directory is not a directory but the 'library.zip' file: - # this is either a py2exe or a cx_Freeze distribution - srcpath = osp.abspath(osp.join(osp.join(parentdir, osp.pardir), - modname)) - if basename is not None: - srcpath = osp.abspath(osp.join(srcpath, basename)) - return srcpath - - -def is_py2exe_or_cx_Freeze(): - """Return True if this is a py2exe/cx_Freeze distribution of Spyder""" - return osp.isfile(osp.join(get_module_path('spyder'), osp.pardir)) - - -def is_pynsist(): - """Return True if this is a pynsist installation of Spyder.""" - base_path = osp.abspath(osp.dirname(__file__)) - pkgs_path = osp.abspath( - osp.join(base_path, '..', '..', '..', 'pkgs')) - if os.environ.get('PYTHONPATH') is not None: - return pkgs_path in os.environ.get('PYTHONPATH') - return False - - -#============================================================================== -# Translations -#============================================================================== -LANG_FILE = get_conf_path('langconfig') -DEFAULT_LANGUAGE = 'en' - -# This needs to be updated every time a new language is added to spyder, and is -# used by the Preferences configuration to populate the Language QComboBox -LANGUAGE_CODES = { - 'en': u'English', - 'fr': u'Français', - 'es': u'Español', - 'hu': u'Magyar', - 'pt_BR': u'Português', - 'ru': u'Русский', - 'zh_CN': u'简体中文', - 'ja': u'日本語', - 'de': u'Deutsch', - 'pl': u'Polski' -} - -# Disabled languages because their translations are outdated or incomplete -DISABLED_LANGUAGES = ['hu', 'pl'] - - -def get_available_translations(): - """ - List available translations for spyder based on the folders found in the - locale folder. This function checks if LANGUAGE_CODES contain the same - information that is found in the 'locale' folder to ensure that when a new - language is added, LANGUAGE_CODES is updated. - """ - locale_path = get_module_data_path("spyder", relpath="locale", - attr_name='LOCALEPATH') - listdir = os.listdir(locale_path) - langs = [d for d in listdir if osp.isdir(osp.join(locale_path, d))] - langs = [DEFAULT_LANGUAGE] + langs - - # Remove disabled languages - langs = list(set(langs) - set(DISABLED_LANGUAGES)) - - # Check that there is a language code available in case a new translation - # is added, to ensure LANGUAGE_CODES is updated. - for lang in langs: - if lang not in LANGUAGE_CODES: - if DEV: - error = ('Update LANGUAGE_CODES (inside config/base.py) if a ' - 'new translation has been added to Spyder') - print(error) # spyder: test-skip - return ['en'] - return langs - - -def get_interface_language(): - """ - If Spyder has a translation available for the locale language, it will - return the version provided by Spyder adjusted for language subdifferences, - otherwise it will return DEFAULT_LANGUAGE. - - Example: - 1.) Spyder provides ('en', 'de', 'fr', 'es' 'hu' and 'pt_BR'), if the - locale is either 'en_US' or 'en' or 'en_UK', this function will return 'en' - - 2.) Spyder provides ('en', 'de', 'fr', 'es' 'hu' and 'pt_BR'), if the - locale is either 'pt' or 'pt_BR', this function will return 'pt_BR' - """ - - # Solves spyder-ide/spyder#3627. - try: - locale_language = locale.getdefaultlocale()[0] - except ValueError: - locale_language = DEFAULT_LANGUAGE - - # Tests expect English as the interface language - if running_under_pytest(): - locale_language = DEFAULT_LANGUAGE - - language = DEFAULT_LANGUAGE - - if locale_language is not None: - spyder_languages = get_available_translations() - for lang in spyder_languages: - if locale_language == lang: - language = locale_language - break - elif (locale_language.startswith(lang) or - lang.startswith(locale_language)): - language = lang - break - - return language - - -def save_lang_conf(value): - """Save language setting to language config file""" - # Needed to avoid an error when trying to save LANG_FILE - # but the operation fails for some reason. - # See spyder-ide/spyder#8807. - try: - with open(LANG_FILE, 'w') as f: - f.write(value) - except EnvironmentError: - pass - - -def load_lang_conf(): - """ - Load language setting from language config file if it exists, otherwise - try to use the local settings if Spyder provides a translation, or - return the default if no translation provided. - """ - if osp.isfile(LANG_FILE): - with open(LANG_FILE, 'r') as f: - lang = f.read() - else: - lang = get_interface_language() - save_lang_conf(lang) - - # Save language again if it's been disabled - if lang.strip('\n') in DISABLED_LANGUAGES: - lang = DEFAULT_LANGUAGE - save_lang_conf(lang) - - return lang - - -def get_translation(modname, dirname=None): - """Return translation callback for module *modname*""" - if dirname is None: - dirname = modname - - def translate_dumb(x): - """Dumb function to not use translations.""" - if not is_unicode(x): - return to_text_string(x, "utf-8") - return x - - locale_path = get_module_data_path(dirname, relpath="locale", - attr_name='LOCALEPATH') - - # If LANG is defined in Ubuntu, a warning message is displayed, - # so in Unix systems we define the LANGUAGE variable. - language = load_lang_conf() - if os.name == 'nt': - # Trying to set LANG on Windows can fail when Spyder is - # run with admin privileges. - # Fixes spyder-ide/spyder#6886. - try: - os.environ["LANG"] = language # Works on Windows - except Exception: - return translate_dumb - else: - os.environ["LANGUAGE"] = language # Works on Linux - - import gettext - try: - _trans = gettext.translation(modname, locale_path, codeset="utf-8") - lgettext = _trans.lgettext - - def translate_gettext(x): - if not PY3 and is_unicode(x): - x = x.encode("utf-8") - y = lgettext(x) - if is_text_string(y) and PY3: - return y - else: - return to_text_string(y, "utf-8") - return translate_gettext - except Exception: - return translate_dumb - - -# Translation callback -_ = get_translation("spyder") - - -#============================================================================== -# Namespace Browser (Variable Explorer) configuration management -#============================================================================== -# Variable explorer display / check all elements data types for sequences: -# (when saving the variable explorer contents, check_all is True, -CHECK_ALL = False # XXX: If True, this should take too much to compute... - -EXCLUDED_NAMES = ['nan', 'inf', 'infty', 'little_endian', 'colorbar_doc', - 'typecodes', '__builtins__', '__main__', '__doc__', 'NaN', - 'Inf', 'Infinity', 'sctypes', 'rcParams', 'rcParamsDefault', - 'sctypeNA', 'typeNA', 'False_', 'True_'] - - -#============================================================================== -# Mac application utilities -#============================================================================== -def running_in_mac_app(pyexec=None): - """ - Check if Python executable is located inside a standalone Mac app. - - If no executable is provided, the default will check `sys.executable`, i.e. - whether Spyder is running from a standalone Mac app. - - This is important for example for the single_instance option and the - interpreter status in the statusbar. - """ - if pyexec is None: - pyexec = sys.executable - - bpath = get_mac_app_bundle_path() - - if bpath and pyexec == osp.join(bpath, 'Contents/MacOS/python'): - return True - else: - return False - - -def get_mac_app_bundle_path(): - """ - Return the full path to the macOS app bundle. Otherwise return None. - - EXECUTABLEPATH environment variable only exists if Spyder is a macOS app - bundle. In which case it will always end with - "/.app/Conents/MacOS/Spyder". - """ - app_exe_path = os.environ.get('EXECUTABLEPATH', None) - if sys.platform == "darwin" and app_exe_path: - return osp.dirname(osp.dirname(osp.dirname(osp.abspath(app_exe_path)))) - else: - return None - - -# ============================================================================= -# Micromamba -# ============================================================================= -def get_spyder_umamba_path(): - """Return the path to the Micromamba executable bundled with Spyder.""" - if running_in_mac_app(): - path = osp.join(osp.dirname(osp.dirname(__file__)), - 'bin', 'micromamba') - elif is_pynsist(): - path = osp.abspath(osp.join(osp.dirname(osp.dirname(__file__)), - 'bin', 'micromamba.exe')) - else: - path = None - - return path - - -#============================================================================== -# Reset config files -#============================================================================== -SAVED_CONFIG_FILES = ('help', 'onlinehelp', 'path', 'pylint.results', - 'spyder.ini', 'temp.py', 'temp.spydata', 'template.py', - 'history.py', 'history_internal.py', 'workingdir', - '.projects', '.spyproject', '.ropeproject', - 'monitor.log', 'monitor_debug.log', 'rope.log', - 'langconfig', 'spyder.lock', - 'config{}spyder.ini'.format(os.sep), - 'config{}transient.ini'.format(os.sep), - 'lsp_root_path', 'plugins') - - -def reset_config_files(): - """Remove all config files""" - print("*** Reset Spyder settings to defaults ***", file=STDERR) - for fname in SAVED_CONFIG_FILES: - cfg_fname = get_conf_path(fname) - if osp.isfile(cfg_fname) or osp.islink(cfg_fname): - os.remove(cfg_fname) - elif osp.isdir(cfg_fname): - shutil.rmtree(cfg_fname) - else: - continue - print("removing:", cfg_fname, file=STDERR) +# -*- coding: utf-8 -*- +# +# Copyright © Spyder Project Contributors +# Licensed under the terms of the MIT License +# (see spyder/__init__.py for details) + +""" +Spyder base configuration management + +This file only deals with non-GUI configuration features +(in other words, we won't import any PyQt object here, avoiding any +sip API incompatibility issue in spyder's non-gui modules) +""" + +import codecs +import locale +import os +import os.path as osp +import re +import shutil +import sys +import tempfile +import uuid +import warnings + +# Local imports +from spyder import __version__ +from spyder.py3compat import is_unicode, PY3, to_text_string, is_text_string +from spyder.utils import encoding + +#============================================================================== +# Only for development +#============================================================================== +# To activate/deactivate certain things for development +# SPYDER_DEV is (and *only* has to be) set in bootstrap.py +DEV = os.environ.get('SPYDER_DEV') + +# Manually override whether the dev configuration directory is used. +USE_DEV_CONFIG_DIR = os.environ.get('SPYDER_USE_DEV_CONFIG_DIR') + +# Get a random id for the safe-mode config dir +CLEAN_DIR_ID = str(uuid.uuid4()).split('-')[-1] + + +def get_safe_mode(): + """ + Make Spyder use a temp clean configuration directory for testing + purposes SPYDER_SAFE_MODE can be set using the --safe-mode option. + """ + return bool(os.environ.get('SPYDER_SAFE_MODE')) + + +def running_under_pytest(): + """ + Return True if currently running under pytest. + + This function is used to do some adjustment for testing. The environment + variable SPYDER_PYTEST is defined in conftest.py. + """ + return bool(os.environ.get('SPYDER_PYTEST')) + + +def running_in_ci(): + """Return True if currently running under CI.""" + return bool(os.environ.get('CI')) + + +def running_in_ci_with_conda(): + """Return True if currently running under CI with conda packages.""" + return running_in_ci() and bool(os.environ.get('USE_CONDA')) + + +def is_stable_version(version): + """ + Return true if version is stable, i.e. with letters in the final component. + + Stable version examples: ``1.2``, ``1.3.4``, ``1.0.5``. + Non-stable version examples: ``1.3.4beta``, ``0.1.0rc1``, ``3.0.0dev0``. + """ + if not isinstance(version, tuple): + version = version.split('.') + last_part = version[-1] + + if not re.search(r'[a-zA-Z]', last_part): + return True + else: + return False + + +def use_dev_config_dir(use_dev_config_dir=USE_DEV_CONFIG_DIR): + """Return whether the dev configuration directory should used.""" + if use_dev_config_dir is not None: + if use_dev_config_dir.lower() in {'false', '0'}: + use_dev_config_dir = False + else: + use_dev_config_dir = DEV or not is_stable_version(__version__) + + return use_dev_config_dir + + +#============================================================================== +# Debug helpers +#============================================================================== +# This is needed after restarting and using debug_print +STDOUT = sys.stdout if PY3 else codecs.getwriter('utf-8')(sys.stdout) +STDERR = sys.stderr + + +def get_debug_level(): + debug_env = os.environ.get('SPYDER_DEBUG', '') + if not debug_env.isdigit(): + debug_env = bool(debug_env) + return int(debug_env) + + +def debug_print(*message): + """Output debug messages to stdout""" + warnings.warn("debug_print is deprecated; use the logging module instead.") + if get_debug_level(): + ss = STDOUT + if PY3: + # This is needed after restarting and using debug_print + for m in message: + ss.buffer.write(str(m).encode('utf-8')) + print('', file=ss) + else: + print(*message, file=ss) + + +#============================================================================== +# Configuration paths +#============================================================================== +def get_conf_subfolder(): + """Return the configuration subfolder for different ooperating systems.""" + # Spyder settings dir + # NOTE: During the 2.x.x series this dir was named .spyder2, but + # since 3.0+ we've reverted back to use .spyder to simplify major + # updates in version (required when we change APIs by Linux + # packagers) + if sys.platform.startswith('linux'): + SUBFOLDER = 'spyder' + else: + SUBFOLDER = '.spyder' + + # We can't have PY2 and PY3 settings in the same dir because: + # 1. This leads to ugly crashes and freezes (e.g. by trying to + # embed a PY2 interpreter in PY3) + # 2. We need to save the list of installed modules (for code + # completion) separately for each version + if PY3: + SUBFOLDER = SUBFOLDER + '-py3' + + # If running a development/beta version, save config in a separate + # directory to avoid wiping or contaiminating the user's saved stable + # configuration. + if use_dev_config_dir(): + SUBFOLDER = SUBFOLDER + '-dev' + + return SUBFOLDER + + +def get_project_config_folder(): + """Return the default project configuration folder.""" + return '.spyproject' + + +def get_home_dir(): + """Return user home directory.""" + try: + # expanduser() returns a raw byte string which needs to be + # decoded with the codec that the OS is using to represent + # file paths. + path = encoding.to_unicode_from_fs(osp.expanduser('~')) + except Exception: + path = '' + + if osp.isdir(path): + return path + else: + # Get home from alternative locations + for env_var in ('HOME', 'USERPROFILE', 'TMP'): + # os.environ.get() returns a raw byte string which needs to be + # decoded with the codec that the OS is using to represent + # environment variables. + path = encoding.to_unicode_from_fs(os.environ.get(env_var, '')) + if osp.isdir(path): + return path + else: + path = '' + + if not path: + raise RuntimeError('Please set the environment variable HOME to ' + 'your user/home directory path so Spyder can ' + 'start properly.') + + +def get_clean_conf_dir(): + """ + Return the path to a temp clean configuration dir, for tests and safe mode. + """ + conf_dir = osp.join( + tempfile.gettempdir(), + 'spyder-clean-conf-dirs', + CLEAN_DIR_ID, + ) + return conf_dir + + +def get_custom_conf_dir(): + """ + Use a custom configuration directory, passed through our command + line options or by setting the env var below. + """ + custom_dir = os.environ.get('SPYDER_CONFDIR') + if custom_dir: + custom_dir = osp.abspath(custom_dir) + + # Set env var to not lose its value in future calls when the cwd + # is changed by Spyder. + os.environ['SPYDER_CONFDIR'] = custom_dir + return custom_dir + + +def get_conf_path(filename=None): + """Return absolute path to the config file with the specified filename.""" + # Define conf_dir + if running_under_pytest() or get_safe_mode(): + # Use clean config dir if running tests or the user requests it. + conf_dir = get_clean_conf_dir() + elif get_custom_conf_dir(): + # Use a custom directory if the user decided to do it through + # our command line options. + conf_dir = get_custom_conf_dir() + elif sys.platform.startswith('linux'): + # This makes us follow the XDG standard to save our settings + # on Linux, as it was requested on spyder-ide/spyder#2629. + xdg_config_home = os.environ.get('XDG_CONFIG_HOME', '') + if not xdg_config_home: + xdg_config_home = osp.join(get_home_dir(), '.config') + + if not osp.isdir(xdg_config_home): + os.makedirs(xdg_config_home) + + conf_dir = osp.join(xdg_config_home, get_conf_subfolder()) + else: + conf_dir = osp.join(get_home_dir(), get_conf_subfolder()) + + # Create conf_dir + if not osp.isdir(conf_dir): + if running_under_pytest() or get_safe_mode() or get_custom_conf_dir(): + os.makedirs(conf_dir) + else: + os.mkdir(conf_dir) + + if filename is None: + return conf_dir + else: + return osp.join(conf_dir, filename) + + +def get_conf_paths(): + """Return the files that can update system configuration defaults.""" + CONDA_PREFIX = os.environ.get('CONDA_PREFIX', None) + + if os.name == 'nt': + SEARCH_PATH = ( + 'C:/ProgramData/spyder', + ) + else: + SEARCH_PATH = ( + '/etc/spyder', + '/usr/local/etc/spyder', + ) + + if CONDA_PREFIX is not None: + CONDA_PREFIX = CONDA_PREFIX.replace('\\', '/') + SEARCH_PATH += ( + '{}/etc/spyder'.format(CONDA_PREFIX), + ) + + SEARCH_PATH += ( + '{}/etc/spyder'.format(sys.prefix), + ) + + if running_under_pytest(): + search_paths = [] + tmpfolder = str(tempfile.gettempdir()) + for i in range(3): + path = os.path.join(tmpfolder, 'site-config-' + str(i)) + if not os.path.isdir(path): + os.makedirs(path) + search_paths.append(path) + SEARCH_PATH = tuple(search_paths) + + return SEARCH_PATH + + +def get_module_path(modname): + """Return module *modname* base path""" + return osp.abspath(osp.dirname(sys.modules[modname].__file__)) + + +def get_module_data_path(modname, relpath=None, attr_name='DATAPATH'): + """Return module *modname* data path + Note: relpath is ignored if module has an attribute named *attr_name* + + Handles py2exe/cx_Freeze distributions""" + datapath = getattr(sys.modules[modname], attr_name, '') + if datapath: + return datapath + else: + datapath = get_module_path(modname) + parentdir = osp.join(datapath, osp.pardir) + if osp.isfile(parentdir): + # Parent directory is not a directory but the 'library.zip' file: + # this is either a py2exe or a cx_Freeze distribution + datapath = osp.abspath(osp.join(osp.join(parentdir, osp.pardir), + modname)) + if relpath is not None: + datapath = osp.abspath(osp.join(datapath, relpath)) + return datapath + + +def get_module_source_path(modname, basename=None): + """Return module *modname* source path + If *basename* is specified, return *modname.basename* path where + *modname* is a package containing the module *basename* + + *basename* is a filename (not a module name), so it must include the + file extension: .py or .pyw + + Handles py2exe/cx_Freeze distributions""" + srcpath = get_module_path(modname) + parentdir = osp.join(srcpath, osp.pardir) + if osp.isfile(parentdir): + # Parent directory is not a directory but the 'library.zip' file: + # this is either a py2exe or a cx_Freeze distribution + srcpath = osp.abspath(osp.join(osp.join(parentdir, osp.pardir), + modname)) + if basename is not None: + srcpath = osp.abspath(osp.join(srcpath, basename)) + return srcpath + + +def is_py2exe_or_cx_Freeze(): + """Return True if this is a py2exe/cx_Freeze distribution of Spyder""" + return osp.isfile(osp.join(get_module_path('spyder'), osp.pardir)) + + +def is_pynsist(): + """Return True if this is a pynsist installation of Spyder.""" + base_path = osp.abspath(osp.dirname(__file__)) + pkgs_path = osp.abspath( + osp.join(base_path, '..', '..', '..', 'pkgs')) + if os.environ.get('PYTHONPATH') is not None: + return pkgs_path in os.environ.get('PYTHONPATH') + return False + + +#============================================================================== +# Translations +#============================================================================== +LANG_FILE = get_conf_path('langconfig') +DEFAULT_LANGUAGE = 'en' + +# This needs to be updated every time a new language is added to spyder, and is +# used by the Preferences configuration to populate the Language QComboBox +LANGUAGE_CODES = { + 'en': u'English', + 'fr': u'Français', + 'es': u'Español', + 'hu': u'Magyar', + 'pt_BR': u'Português', + 'ru': u'Русский', + 'zh_CN': u'简体中文', + 'ja': u'日本語', + 'de': u'Deutsch', + 'pl': u'Polski' +} + +# Disabled languages because their translations are outdated or incomplete +DISABLED_LANGUAGES = ['hu', 'pl'] + + +def get_available_translations(): + """ + List available translations for spyder based on the folders found in the + locale folder. This function checks if LANGUAGE_CODES contain the same + information that is found in the 'locale' folder to ensure that when a new + language is added, LANGUAGE_CODES is updated. + """ + locale_path = get_module_data_path("spyder", relpath="locale", + attr_name='LOCALEPATH') + listdir = os.listdir(locale_path) + langs = [d for d in listdir if osp.isdir(osp.join(locale_path, d))] + langs = [DEFAULT_LANGUAGE] + langs + + # Remove disabled languages + langs = list(set(langs) - set(DISABLED_LANGUAGES)) + + # Check that there is a language code available in case a new translation + # is added, to ensure LANGUAGE_CODES is updated. + for lang in langs: + if lang not in LANGUAGE_CODES: + if DEV: + error = ('Update LANGUAGE_CODES (inside config/base.py) if a ' + 'new translation has been added to Spyder') + print(error) # spyder: test-skip + return ['en'] + return langs + + +def get_interface_language(): + """ + If Spyder has a translation available for the locale language, it will + return the version provided by Spyder adjusted for language subdifferences, + otherwise it will return DEFAULT_LANGUAGE. + + Example: + 1.) Spyder provides ('en', 'de', 'fr', 'es' 'hu' and 'pt_BR'), if the + locale is either 'en_US' or 'en' or 'en_UK', this function will return 'en' + + 2.) Spyder provides ('en', 'de', 'fr', 'es' 'hu' and 'pt_BR'), if the + locale is either 'pt' or 'pt_BR', this function will return 'pt_BR' + """ + + # Solves spyder-ide/spyder#3627. + try: + locale_language = locale.getdefaultlocale()[0] + except ValueError: + locale_language = DEFAULT_LANGUAGE + + # Tests expect English as the interface language + if running_under_pytest(): + locale_language = DEFAULT_LANGUAGE + + language = DEFAULT_LANGUAGE + + if locale_language is not None: + spyder_languages = get_available_translations() + for lang in spyder_languages: + if locale_language == lang: + language = locale_language + break + elif (locale_language.startswith(lang) or + lang.startswith(locale_language)): + language = lang + break + + return language + + +def save_lang_conf(value): + """Save language setting to language config file""" + # Needed to avoid an error when trying to save LANG_FILE + # but the operation fails for some reason. + # See spyder-ide/spyder#8807. + try: + with open(LANG_FILE, 'w') as f: + f.write(value) + except EnvironmentError: + pass + + +def load_lang_conf(): + """ + Load language setting from language config file if it exists, otherwise + try to use the local settings if Spyder provides a translation, or + return the default if no translation provided. + """ + if osp.isfile(LANG_FILE): + with open(LANG_FILE, 'r') as f: + lang = f.read() + else: + lang = get_interface_language() + save_lang_conf(lang) + + # Save language again if it's been disabled + if lang.strip('\n') in DISABLED_LANGUAGES: + lang = DEFAULT_LANGUAGE + save_lang_conf(lang) + + return lang + + +def get_translation(modname, dirname=None): + """Return translation callback for module *modname*""" + if dirname is None: + dirname = modname + + def translate_dumb(x): + """Dumb function to not use translations.""" + if not is_unicode(x): + return to_text_string(x, "utf-8") + return x + + locale_path = get_module_data_path(dirname, relpath="locale", + attr_name='LOCALEPATH') + + # If LANG is defined in Ubuntu, a warning message is displayed, + # so in Unix systems we define the LANGUAGE variable. + language = load_lang_conf() + if os.name == 'nt': + # Trying to set LANG on Windows can fail when Spyder is + # run with admin privileges. + # Fixes spyder-ide/spyder#6886. + try: + os.environ["LANG"] = language # Works on Windows + except Exception: + return translate_dumb + else: + os.environ["LANGUAGE"] = language # Works on Linux + + import gettext + try: + _trans = gettext.translation(modname, locale_path, codeset="utf-8") + lgettext = _trans.lgettext + + def translate_gettext(x): + if not PY3 and is_unicode(x): + x = x.encode("utf-8") + y = lgettext(x) + if is_text_string(y) and PY3: + return y + else: + return to_text_string(y, "utf-8") + return translate_gettext + except Exception: + return translate_dumb + + +# Translation callback +_ = get_translation("spyder") + + +#============================================================================== +# Namespace Browser (Variable Explorer) configuration management +#============================================================================== +# Variable explorer display / check all elements data types for sequences: +# (when saving the variable explorer contents, check_all is True, +CHECK_ALL = False # XXX: If True, this should take too much to compute... + +EXCLUDED_NAMES = ['nan', 'inf', 'infty', 'little_endian', 'colorbar_doc', + 'typecodes', '__builtins__', '__main__', '__doc__', 'NaN', + 'Inf', 'Infinity', 'sctypes', 'rcParams', 'rcParamsDefault', + 'sctypeNA', 'typeNA', 'False_', 'True_'] + + +#============================================================================== +# Mac application utilities +#============================================================================== +def running_in_mac_app(pyexec=None): + """ + Check if Python executable is located inside a standalone Mac app. + + If no executable is provided, the default will check `sys.executable`, i.e. + whether Spyder is running from a standalone Mac app. + + This is important for example for the single_instance option and the + interpreter status in the statusbar. + """ + if pyexec is None: + pyexec = sys.executable + + bpath = get_mac_app_bundle_path() + + if bpath and pyexec == osp.join(bpath, 'Contents/MacOS/python'): + return True + else: + return False + + +def get_mac_app_bundle_path(): + """ + Return the full path to the macOS app bundle. Otherwise return None. + + EXECUTABLEPATH environment variable only exists if Spyder is a macOS app + bundle. In which case it will always end with + "/.app/Conents/MacOS/Spyder". + """ + app_exe_path = os.environ.get('EXECUTABLEPATH', None) + if sys.platform == "darwin" and app_exe_path: + return osp.dirname(osp.dirname(osp.dirname(osp.abspath(app_exe_path)))) + else: + return None + + +# ============================================================================= +# Micromamba +# ============================================================================= +def get_spyder_umamba_path(): + """Return the path to the Micromamba executable bundled with Spyder.""" + if running_in_mac_app(): + path = osp.join(osp.dirname(osp.dirname(__file__)), + 'bin', 'micromamba') + elif is_pynsist(): + path = osp.abspath(osp.join(osp.dirname(osp.dirname(__file__)), + 'bin', 'micromamba.exe')) + else: + path = None + + return path + + +#============================================================================== +# Reset config files +#============================================================================== +SAVED_CONFIG_FILES = ('help', 'onlinehelp', 'path', 'pylint.results', + 'spyder.ini', 'temp.py', 'temp.spydata', 'template.py', + 'history.py', 'history_internal.py', 'workingdir', + '.projects', '.spyproject', '.ropeproject', + 'monitor.log', 'monitor_debug.log', 'rope.log', + 'langconfig', 'spyder.lock', + 'config{}spyder.ini'.format(os.sep), + 'config{}transient.ini'.format(os.sep), + 'lsp_root_path', 'plugins') + + +def reset_config_files(): + """Remove all config files""" + print("*** Reset Spyder settings to defaults ***", file=STDERR) + for fname in SAVED_CONFIG_FILES: + cfg_fname = get_conf_path(fname) + if osp.isfile(cfg_fname) or osp.islink(cfg_fname): + os.remove(cfg_fname) + elif osp.isdir(cfg_fname): + shutil.rmtree(cfg_fname) + else: + continue + print("removing:", cfg_fname, file=STDERR) diff --git a/spyder/config/main.py b/spyder/config/main.py index 6248fd22eb2..0d7654fb991 100644 --- a/spyder/config/main.py +++ b/spyder/config/main.py @@ -1,643 +1,643 @@ -# -*- coding: utf-8 -*- -# -# Copyright © Spyder Project Contributors -# Licensed under the terms of the MIT License -# (see spyder/__init__.py for details) - -""" -Spyder configuration options. - -Note: Leave this file free of Qt related imports, so that it can be used to -quickly load a user config file. -""" - -import os -import sys - -# Local import -from spyder.config.base import CHECK_ALL, EXCLUDED_NAMES -from spyder.config.fonts import MEDIUM, SANS_SERIF -from spyder.config.utils import IMPORT_EXT -from spyder.config.appearance import APPEARANCE -from spyder.plugins.editor.utils.findtasks import TASKS_PATTERN -from spyder.utils.introspection.module_completion import PREFERRED_MODULES - - -# ============================================================================= -# Main constants -# ============================================================================= -# Find in files exclude patterns -EXCLUDE_PATTERNS = ['*.csv, *.dat, *.log, *.tmp, *.bak, *.orig'] - -# Extensions that should be visible in Spyder's file/project explorers -SHOW_EXT = ['.py', '.ipynb', '.dat', '.pdf', '.png', '.svg', '.md', '.yml', - '.yaml'] - -# Extensions supported by Spyder (Editor or Variable explorer) -USEFUL_EXT = IMPORT_EXT + SHOW_EXT - -# Name filters for file/project explorers (excluding files without extension) -NAME_FILTERS = ['README', 'INSTALL', 'LICENSE', 'CHANGELOG'] -NAME_FILTERS += ['*' + _ext for _ext in USEFUL_EXT if _ext not in NAME_FILTERS] - -# Port used to detect if there is a running instance and to communicate with -# it to open external files -OPEN_FILES_PORT = 21128 - -# OS Specific -WIN = os.name == 'nt' -MAC = sys.platform == 'darwin' -LINUX = sys.platform.startswith('linux') -CTRL = "Meta" if MAC else "Ctrl" - -# Modules to be preloaded for Rope and Jedi -PRELOAD_MDOULES = ', '.join(PREFERRED_MODULES) - - -# ============================================================================= -# Defaults -# ============================================================================= -DEFAULTS = [ - ('main', - { - 'opengl': 'software', - 'single_instance': True, - 'open_files_port': OPEN_FILES_PORT, - 'mac_open_file': False, - 'normal_screen_resolution': True, - 'high_dpi_scaling': False, - 'high_dpi_custom_scale_factor': False, - 'high_dpi_custom_scale_factors': '1.5', - 'vertical_tabs': False, - 'prompt_on_exit': False, - 'panes_locked': True, - 'window/size': (1260, 740), - '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, - 'show_internal_errors': True, - 'check_updates_on_startup': True, - 'cursor/width': 2, - 'completion/size': (300, 180), - 'report_error/remember_token': False, - 'show_dpi_message': True, - }), - ('toolbar', - { - 'enable': True, - 'toolbars_visible': True, - 'last_visible_toolbars': [], - }), - ('statusbar', - { - 'show_status_bar': True, - 'memory_usage/enable': True, - 'memory_usage/timeout': 2000, - 'cpu_usage/enable': False, - 'cpu_usage/timeout': 2000, - 'clock/enable': False, - 'clock/timeout': 1000, - }), - ('quick_layouts', - { - 'place_holder': '', - 'names': [], - 'order': [], - 'active': [], - 'ui_names': [] - }), - ('internal_console', - { - 'max_line_count': 300, - 'working_dir_history': 30, - 'working_dir_adjusttocontents': False, - 'wrap': True, - 'codecompletion/auto': False, - 'external_editor/path': 'SciTE', - 'external_editor/gotoline': '-goto:', - }), - ('main_interpreter', - { - 'default': True, - 'custom': False, - 'umr/enabled': True, - 'umr/verbose': True, - 'umr/namelist': [], - 'custom_interpreters_list': [], - 'custom_interpreter': '', - }), - ('ipython_console', - { - 'show_banner': True, - 'completion_type': 0, - 'show_calltips': True, - 'ask_before_closing': False, - 'show_reset_namespace_warning': True, - 'buffer_size': 500, - 'pylab': True, - 'pylab/autoload': False, - 'pylab/backend': 0, - 'pylab/inline/figure_format': 0, - 'pylab/inline/resolution': 72, - 'pylab/inline/width': 6, - 'pylab/inline/height': 4, - 'pylab/inline/bbox_inches': True, - 'startup/run_lines': '', - 'startup/use_run_file': False, - 'startup/run_file': '', - 'greedy_completer': False, - 'jedi_completer': False, - 'autocall': 0, - 'symbolic_math': False, - 'in_prompt': '', - 'out_prompt': '', - 'show_elapsed_time': False, - 'ask_before_restart': True, - # This is True because there are libraries like Pyomo - # that generate a lot of Command Prompts while running, - # and that's extremely annoying for Windows users. - 'hide_cmd_windows': True, - 'pdb_prevent_closing': True, - 'pdb_ignore_lib': False, - 'pdb_execute_events': True, - 'pdb_use_exclamation_mark': True, - 'pdb_stop_first_line': True - }), - ('variable_explorer', - { - 'check_all': CHECK_ALL, - 'dataframe_format': '.6g', # No percent sign to avoid problems - # with ConfigParser's interpolation - 'excluded_names': EXCLUDED_NAMES, - 'exclude_private': True, - 'exclude_uppercase': False, - 'exclude_capitalized': False, - 'exclude_unsupported': False, - 'exclude_callables_and_modules': True, - 'truncate': True, - 'minmax': False, - 'show_callable_attributes': True, - 'show_special_attributes': False - }), - ('plots', - { - 'mute_inline_plotting': True, - 'show_plot_outline': False, - 'auto_fit_plotting': True - }), - ('editor', - { - 'printer_header/font/family': SANS_SERIF, - 'printer_header/font/size': MEDIUM, - 'printer_header/font/italic': False, - 'printer_header/font/bold': False, - 'wrap': False, - 'wrapflag': True, - 'todo_list': True, - 'realtime_analysis': True, - 'realtime_analysis/timeout': 2500, - 'outline_explorer': True, - 'line_numbers': True, - 'blank_spaces': False, - 'edge_line': True, - 'edge_line_columns': '79', - 'indent_guides': False, - 'code_folding': True, - 'show_code_folding_warning': True, - 'scroll_past_end': False, - 'toolbox_panel': True, - 'close_parentheses': True, - 'close_quotes': True, - 'add_colons': True, - 'auto_unindent': True, - 'indent_chars': '* *', - 'tab_stop_width_spaces': 4, - 'check_eol_chars': True, - 'convert_eol_on_save': False, - 'convert_eol_on_save_to': 'LF', - 'tab_always_indent': False, - 'intelligent_backspace': True, - 'automatic_completions': True, - 'automatic_completions_after_chars': 3, - 'automatic_completions_after_ms': 300, - 'completions_hint': True, - 'completions_hint_after_ms': 500, - 'underline_errors': False, - 'highlight_current_line': True, - 'highlight_current_cell': True, - 'occurrence_highlighting': True, - 'occurrence_highlighting/timeout': 1500, - 'always_remove_trailing_spaces': False, - 'add_newline': False, - 'always_remove_trailing_newlines': False, - 'show_tab_bar': True, - 'show_class_func_dropdown': False, - 'max_recent_files': 20, - 'save_all_before_run': True, - 'focus_to_editor': True, - 'run_cell_copy': False, - 'onsave_analysis': False, - 'autosave_enabled': True, - 'autosave_interval': 60, - 'docstring_type': 'Numpydoc', - 'strip_trailing_spaces_on_modify': False, - }), - ('historylog', - { - 'enable': True, - 'wrap': True, - 'go_to_eof': True, - 'line_numbers': False, - }), - ('help', - { - 'enable': True, - 'max_history_entries': 20, - 'wrap': True, - 'connect/editor': False, - 'connect/ipython_console': False, - 'math': True, - 'automatic_import': True, - 'plain_mode': False, - 'rich_mode': True, - 'show_source': False, - 'locked': False, - }), - ('onlinehelp', - { - 'enable': True, - 'zoom_factor': .8, - 'handle_links': False, - 'max_history_entries': 20, - }), - ('outline_explorer', - { - 'enable': True, - 'show_fullpath': False, - 'show_all_files': False, - 'group_cells': True, - 'sort_files_alphabetically': False, - 'show_comments': True, - 'follow_cursor': True, - 'display_variables': False - }), - ('project_explorer', - { - 'name_filters': NAME_FILTERS, - 'show_all': True, - 'show_hscrollbar': True, - 'max_recent_projects': 10, - 'visible_if_project_open': True, - 'date_column': False, - 'single_click_to_open': False, - 'show_hidden': True, - 'size_column': False, - 'type_column': False, - 'date_column': False - }), - ('explorer', - { - 'enable': True, - 'name_filters': NAME_FILTERS, - 'show_hidden': False, - 'single_click_to_open': False, - 'size_column': False, - 'type_column': False, - 'date_column': True - }), - ('find_in_files', - { - 'enable': True, - 'supported_encodings': ["utf-8", "iso-8859-1", "cp1252"], - 'exclude': EXCLUDE_PATTERNS, - 'exclude_regexp': False, - 'search_text_regexp': False, - 'search_text': [''], - 'search_text_samples': [TASKS_PATTERN], - 'more_options': False, - 'case_sensitive': False, - 'exclude_case_sensitive': False, - 'max_results': 1000, - }), - ('breakpoints', - { - 'enable': True, - }), - ('completions', - { - 'enable': True, - 'kite_call_to_action': False, - 'enable_code_snippets': True, - 'completions_wait_for_ms': 200, - 'enabled_providers': {}, - 'provider_configuration': {}, - 'request_priorities': {} - }), - ('profiler', - { - 'enable': True, - }), - ('pylint', - { - 'enable': True, - 'history_filenames': [], - 'max_entries': 30, - 'project_dir': None, - }), - ('workingdir', - { - 'working_dir_adjusttocontents': False, - 'working_dir_history': 20, - 'console/use_project_or_home_directory': False, - 'console/use_cwd': True, - 'console/use_fixed_directory': False, - 'startup/use_project_or_home_directory': True, - 'startup/use_fixed_directory': False, - }), - ('tours', - { - 'enable': True, - 'show_tour_message': True, - }), - ('shortcuts', - { - # ---- Global ---- - # -- In app/spyder.py - '_/close pane': "Shift+Ctrl+F4", - '_/lock unlock panes': "Shift+Ctrl+F5", - '_/use next layout': "Shift+Alt+PgDown", - '_/use previous layout': "Shift+Alt+PgUp", - '_/maximize pane': "Ctrl+Alt+Shift+M", - '_/fullscreen mode': "F11", - '_/save current layout': "Shift+Alt+S", - '_/layout preferences': "Shift+Alt+P", - '_/spyder documentation': "F1", - '_/restart': "Shift+Alt+R", - '_/quit': "Ctrl+Q", - # -- In plugins/editor - '_/file switcher': 'Ctrl+P', - '_/symbol finder': 'Ctrl+Alt+P', - '_/debug': "Ctrl+F5", - '_/debug step over': "Ctrl+F10", - '_/debug continue': "Ctrl+F12", - '_/debug step into': "Ctrl+F11", - '_/debug step return': "Ctrl+Shift+F11", - '_/debug exit': "Ctrl+Shift+F12", - '_/run': "F5", - '_/configure': "Ctrl+F6", - '_/re-run last script': "F6", - # -- In plugins/init - '_/switch to help': "Ctrl+Shift+H", - '_/switch to outline_explorer': "Ctrl+Shift+O", - '_/switch to editor': "Ctrl+Shift+E", - '_/switch to historylog': "Ctrl+Shift+L", - '_/switch to onlinehelp': "Ctrl+Shift+D", - '_/switch to project_explorer': "Ctrl+Shift+P", - '_/switch to ipython_console': "Ctrl+Shift+I", - '_/switch to variable_explorer': "Ctrl+Shift+V", - '_/switch to find_in_files': "Ctrl+Shift+F", - '_/switch to explorer': "Ctrl+Shift+X", - '_/switch to plots': "Ctrl+Shift+G", - '_/switch to pylint': "Ctrl+Shift+C", - '_/switch to profiler': "Ctrl+Shift+R", - # -- In widgets/findreplace.py - 'find_replace/find text': "Ctrl+F", - 'find_replace/find next': "F3", - 'find_replace/find previous': "Shift+F3", - 'find_replace/replace text': "Ctrl+R", - 'find_replace/hide find and replace': "Escape", - # ---- Editor ---- - # -- In widgets/sourcecode/codeeditor.py - 'editor/code completion': CTRL+'+Space', - 'editor/duplicate line up': ( - "Ctrl+Alt+Up" if WIN else "Shift+Alt+Up"), - 'editor/duplicate line down': ( - "Ctrl+Alt+Down" if WIN else "Shift+Alt+Down"), - 'editor/delete line': 'Ctrl+D', - 'editor/transform to uppercase': 'Ctrl+Shift+U', - 'editor/transform to lowercase': 'Ctrl+U', - 'editor/indent': 'Ctrl+]', - 'editor/unindent': 'Ctrl+[', - 'editor/move line up': "Alt+Up", - 'editor/move line down': "Alt+Down", - 'editor/go to new line': "Ctrl+Shift+Return", - 'editor/go to definition': "Ctrl+G", - 'editor/toggle comment': "Ctrl+1", - 'editor/blockcomment': "Ctrl+4", - 'editor/unblockcomment': "Ctrl+5", - 'editor/start of line': "Meta+A", - 'editor/end of line': "Meta+E", - 'editor/previous line': "Meta+P", - 'editor/next line': "Meta+N", - 'editor/previous char': "Meta+B", - 'editor/next char': "Meta+F", - 'editor/previous word': "Ctrl+Left", - 'editor/next word': "Ctrl+Right", - 'editor/kill to line end': "Meta+K", - 'editor/kill to line start': "Meta+U", - 'editor/yank': 'Meta+Y', - 'editor/rotate kill ring': 'Shift+Meta+Y', - 'editor/kill previous word': 'Meta+Backspace', - 'editor/kill next word': 'Meta+D', - 'editor/start of document': 'Ctrl+Home', - 'editor/end of document': 'Ctrl+End', - 'editor/undo': 'Ctrl+Z', - 'editor/redo': 'Ctrl+Shift+Z', - 'editor/cut': 'Ctrl+X', - 'editor/copy': 'Ctrl+C', - 'editor/paste': 'Ctrl+V', - 'editor/delete': 'Del', - 'editor/select all': "Ctrl+A", - # -- In widgets/editor.py - 'editor/inspect current object': 'Ctrl+I', - 'editor/breakpoint': 'F12', - 'editor/conditional breakpoint': 'Shift+F12', - 'editor/run selection': "F9", - 'editor/run to line': 'Shift+F9', - 'editor/run from line': CTRL + '+F9', - 'editor/go to line': 'Ctrl+L', - 'editor/go to previous file': CTRL + '+Shift+Tab', - 'editor/go to next file': CTRL + '+Tab', - 'editor/cycle to previous file': 'Ctrl+PgUp', - 'editor/cycle to next file': 'Ctrl+PgDown', - 'editor/new file': "Ctrl+N", - 'editor/open last closed':"Ctrl+Shift+T", - 'editor/open file': "Ctrl+O", - 'editor/save file': "Ctrl+S", - 'editor/save all': "Ctrl+Alt+S", - 'editor/save as': 'Ctrl+Shift+S', - 'editor/close all': "Ctrl+Shift+W", - 'editor/last edit location': "Ctrl+Alt+Shift+Left", - 'editor/previous cursor position': "Alt+Left", - 'editor/next cursor position': "Alt+Right", - 'editor/previous warning': "Ctrl+Alt+Shift+,", - 'editor/next warning': "Ctrl+Alt+Shift+.", - 'editor/zoom in 1': "Ctrl++", - 'editor/zoom in 2': "Ctrl+=", - 'editor/zoom out': "Ctrl+-", - 'editor/zoom reset': "Ctrl+0", - 'editor/close file 1': "Ctrl+W", - 'editor/close file 2': "Ctrl+F4", - 'editor/run cell': CTRL + '+Return', - 'editor/run cell and advance': 'Shift+Return', - 'editor/debug cell': 'Alt+Shift+Return', - 'editor/go to next cell': 'Ctrl+Down', - 'editor/go to previous cell': 'Ctrl+Up', - 'editor/re-run last cell': 'Alt+Return', - 'editor/split vertically': "Ctrl+{", - 'editor/split horizontally': "Ctrl+_", - 'editor/close split panel': "Alt+Shift+W", - 'editor/docstring': "Ctrl+Alt+D", - 'editor/autoformatting': "Ctrl+Alt+I", - 'editor/show in external file explorer': '', - # -- In Breakpoints - '_/switch to breakpoints': "Ctrl+Shift+B", - # ---- Consoles (in widgets/shell) ---- - 'console/inspect current object': "Ctrl+I", - 'console/clear shell': "Ctrl+L", - 'console/clear line': "Shift+Escape", - # ---- In Pylint ---- - 'pylint/run analysis': "F8", - # ---- In Profiler ---- - 'profiler/run profiler': "F10", - # ---- In widgets/ipythonconsole/shell.py ---- - 'ipython_console/new tab': "Ctrl+T", - 'ipython_console/reset namespace': "Ctrl+Alt+R", - 'ipython_console/restart kernel': "Ctrl+.", - 'ipython_console/inspect current object': "Ctrl+I", - 'ipython_console/clear shell': "Ctrl+L", - 'ipython_console/clear line': "Shift+Escape", - 'ipython_console/enter array inline': "Ctrl+Alt+M", - 'ipython_console/enter array table': "Ctrl+M", - # ---- In widgets/arraybuider.py ---- - 'array_builder/enter array inline': "Ctrl+Alt+M", - 'array_builder/enter array table': "Ctrl+M", - # ---- In widgets/variableexplorer/arrayeditor.py ---- - 'variable_explorer/copy': 'Ctrl+C', - # ---- In widgets/variableexplorer/namespacebrowser.py ---- - 'variable_explorer/search': 'Ctrl+F', - 'variable_explorer/refresh': 'Ctrl+R', - # ---- In widgets/plots/figurebrowser.py ---- - 'plots/copy': 'Ctrl+C', - 'plots/previous figure': 'Ctrl+PgUp', - 'plots/next figure': 'Ctrl+PgDown', - 'plots/save': 'Ctrl+S', - 'plots/save all': 'Ctrl+Alt+S', - 'plots/close': 'Ctrl+W', - 'plots/close all': 'Ctrl+Shift+W', - 'plots/zoom in': "Ctrl++", - 'plots/zoom out': "Ctrl+-", - # ---- In widgets/explorer ---- - 'explorer/copy file': 'Ctrl+C', - 'explorer/paste file': 'Ctrl+V', - 'explorer/copy absolute path': 'Ctrl+Alt+C', - 'explorer/copy relative path': 'Ctrl+Alt+Shift+C', - # ---- In plugins/findinfiles/plugin ---- - 'find_in_files/find in files': 'Alt+Shift+F', - }), - ('appearance', APPEARANCE), - ] - - -NAME_MAP = { - # Empty container object means use the rest of defaults - 'spyder': [], - # Splitting these files makes sense for projects, we might as well - # apply the same split for the app global config - # These options change on spyder startup or are tied to a specific OS, - # not good for version control - 'transient': [ - ('main', [ - 'completion/size', - 'crash', - 'current_version', - 'historylog_filename', - 'spyder_pythonpath', - 'window/position', - 'window/prefs_dialog_size', - 'window/size', - 'window/state', - ] - ), - ('toolbar', [ - 'last_visible_toolbars', - ] - ), - ('editor', [ - 'autosave_mapping', - 'bookmarks', - 'filenames', - 'layout_settings', - 'recent_files', - 'splitter_state', - ] - ), - ('explorer', [ - 'file_associations', - ]), - ('find_in_files', [ - 'path_history' - 'search_text', - 'exclude_index', - 'search_in_index', - ] - ), - ('main_interpreter', [ - 'custom_interpreters_list', - 'custom_interpreter', - 'executable', - ] - ), - ('onlinehelp', [ - 'zoom_factor', - ] - ), - ('outline_explorer', [ - 'expanded_state', - 'scrollbar_position', - ], - ), - ('project_explorer', [ - 'current_project_path', - 'expanded_state', - 'recent_projects', - 'max_recent_projects', - 'scrollbar_position', - ] - ), - ('quick_layouts', []), # Empty list means use all options - ('run', [ - 'breakpoints', - 'configurations', - 'defaultconfiguration', - 'default/wdir/fixed_directory', - ] - ), - ('workingdir', [ - 'console/fixed_directory', - 'startup/fixed_directory', - ] - ), - ('pylint', [ - 'history_filenames', - ] - ), - ] -} - - -# ============================================================================= -# Config instance -# ============================================================================= -# IMPORTANT NOTES: -# 1. If you want to *change* the default value of a current option, you need to -# do a MINOR update in config version, e.g. from 3.0.0 to 3.1.0 -# 2. If you want to *remove* options that are no longer needed in our codebase, -# 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 = '70.4.0' +# -*- coding: utf-8 -*- +# +# Copyright © Spyder Project Contributors +# Licensed under the terms of the MIT License +# (see spyder/__init__.py for details) + +""" +Spyder configuration options. + +Note: Leave this file free of Qt related imports, so that it can be used to +quickly load a user config file. +""" + +import os +import sys + +# Local import +from spyder.config.base import CHECK_ALL, EXCLUDED_NAMES +from spyder.config.fonts import MEDIUM, SANS_SERIF +from spyder.config.utils import IMPORT_EXT +from spyder.config.appearance import APPEARANCE +from spyder.plugins.editor.utils.findtasks import TASKS_PATTERN +from spyder.utils.introspection.module_completion import PREFERRED_MODULES + + +# ============================================================================= +# Main constants +# ============================================================================= +# Find in files exclude patterns +EXCLUDE_PATTERNS = ['*.csv, *.dat, *.log, *.tmp, *.bak, *.orig'] + +# Extensions that should be visible in Spyder's file/project explorers +SHOW_EXT = ['.py', '.ipynb', '.dat', '.pdf', '.png', '.svg', '.md', '.yml', + '.yaml'] + +# Extensions supported by Spyder (Editor or Variable explorer) +USEFUL_EXT = IMPORT_EXT + SHOW_EXT + +# Name filters for file/project explorers (excluding files without extension) +NAME_FILTERS = ['README', 'INSTALL', 'LICENSE', 'CHANGELOG'] +NAME_FILTERS += ['*' + _ext for _ext in USEFUL_EXT if _ext not in NAME_FILTERS] + +# Port used to detect if there is a running instance and to communicate with +# it to open external files +OPEN_FILES_PORT = 21128 + +# OS Specific +WIN = os.name == 'nt' +MAC = sys.platform == 'darwin' +LINUX = sys.platform.startswith('linux') +CTRL = "Meta" if MAC else "Ctrl" + +# Modules to be preloaded for Rope and Jedi +PRELOAD_MDOULES = ', '.join(PREFERRED_MODULES) + + +# ============================================================================= +# Defaults +# ============================================================================= +DEFAULTS = [ + ('main', + { + 'opengl': 'software', + 'single_instance': True, + 'open_files_port': OPEN_FILES_PORT, + 'mac_open_file': False, + 'normal_screen_resolution': True, + 'high_dpi_scaling': False, + 'high_dpi_custom_scale_factor': False, + 'high_dpi_custom_scale_factors': '1.5', + 'vertical_tabs': False, + 'prompt_on_exit': False, + 'panes_locked': True, + 'window/size': (1260, 740), + '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, + 'show_internal_errors': True, + 'check_updates_on_startup': True, + 'cursor/width': 2, + 'completion/size': (300, 180), + 'report_error/remember_token': False, + 'show_dpi_message': True, + }), + ('toolbar', + { + 'enable': True, + 'toolbars_visible': True, + 'last_visible_toolbars': [], + }), + ('statusbar', + { + 'show_status_bar': True, + 'memory_usage/enable': True, + 'memory_usage/timeout': 2000, + 'cpu_usage/enable': False, + 'cpu_usage/timeout': 2000, + 'clock/enable': False, + 'clock/timeout': 1000, + }), + ('quick_layouts', + { + 'place_holder': '', + 'names': [], + 'order': [], + 'active': [], + 'ui_names': [] + }), + ('internal_console', + { + 'max_line_count': 300, + 'working_dir_history': 30, + 'working_dir_adjusttocontents': False, + 'wrap': True, + 'codecompletion/auto': False, + 'external_editor/path': 'SciTE', + 'external_editor/gotoline': '-goto:', + }), + ('main_interpreter', + { + 'default': True, + 'custom': False, + 'umr/enabled': True, + 'umr/verbose': True, + 'umr/namelist': [], + 'custom_interpreters_list': [], + 'custom_interpreter': '', + }), + ('ipython_console', + { + 'show_banner': True, + 'completion_type': 0, + 'show_calltips': True, + 'ask_before_closing': False, + 'show_reset_namespace_warning': True, + 'buffer_size': 500, + 'pylab': True, + 'pylab/autoload': False, + 'pylab/backend': 0, + 'pylab/inline/figure_format': 0, + 'pylab/inline/resolution': 72, + 'pylab/inline/width': 6, + 'pylab/inline/height': 4, + 'pylab/inline/bbox_inches': True, + 'startup/run_lines': '', + 'startup/use_run_file': False, + 'startup/run_file': '', + 'greedy_completer': False, + 'jedi_completer': False, + 'autocall': 0, + 'symbolic_math': False, + 'in_prompt': '', + 'out_prompt': '', + 'show_elapsed_time': False, + 'ask_before_restart': True, + # This is True because there are libraries like Pyomo + # that generate a lot of Command Prompts while running, + # and that's extremely annoying for Windows users. + 'hide_cmd_windows': True, + 'pdb_prevent_closing': True, + 'pdb_ignore_lib': False, + 'pdb_execute_events': True, + 'pdb_use_exclamation_mark': True, + 'pdb_stop_first_line': True + }), + ('variable_explorer', + { + 'check_all': CHECK_ALL, + 'dataframe_format': '.6g', # No percent sign to avoid problems + # with ConfigParser's interpolation + 'excluded_names': EXCLUDED_NAMES, + 'exclude_private': True, + 'exclude_uppercase': False, + 'exclude_capitalized': False, + 'exclude_unsupported': False, + 'exclude_callables_and_modules': True, + 'truncate': True, + 'minmax': False, + 'show_callable_attributes': True, + 'show_special_attributes': False + }), + ('plots', + { + 'mute_inline_plotting': True, + 'show_plot_outline': False, + 'auto_fit_plotting': True + }), + ('editor', + { + 'printer_header/font/family': SANS_SERIF, + 'printer_header/font/size': MEDIUM, + 'printer_header/font/italic': False, + 'printer_header/font/bold': False, + 'wrap': False, + 'wrapflag': True, + 'todo_list': True, + 'realtime_analysis': True, + 'realtime_analysis/timeout': 2500, + 'outline_explorer': True, + 'line_numbers': True, + 'blank_spaces': False, + 'edge_line': True, + 'edge_line_columns': '79', + 'indent_guides': False, + 'code_folding': True, + 'show_code_folding_warning': True, + 'scroll_past_end': False, + 'toolbox_panel': True, + 'close_parentheses': True, + 'close_quotes': True, + 'add_colons': True, + 'auto_unindent': True, + 'indent_chars': '* *', + 'tab_stop_width_spaces': 4, + 'check_eol_chars': True, + 'convert_eol_on_save': False, + 'convert_eol_on_save_to': 'LF', + 'tab_always_indent': False, + 'intelligent_backspace': True, + 'automatic_completions': True, + 'automatic_completions_after_chars': 3, + 'automatic_completions_after_ms': 300, + 'completions_hint': True, + 'completions_hint_after_ms': 500, + 'underline_errors': False, + 'highlight_current_line': True, + 'highlight_current_cell': True, + 'occurrence_highlighting': True, + 'occurrence_highlighting/timeout': 1500, + 'always_remove_trailing_spaces': False, + 'add_newline': False, + 'always_remove_trailing_newlines': False, + 'show_tab_bar': True, + 'show_class_func_dropdown': False, + 'max_recent_files': 20, + 'save_all_before_run': True, + 'focus_to_editor': True, + 'run_cell_copy': False, + 'onsave_analysis': False, + 'autosave_enabled': True, + 'autosave_interval': 60, + 'docstring_type': 'Numpydoc', + 'strip_trailing_spaces_on_modify': False, + }), + ('historylog', + { + 'enable': True, + 'wrap': True, + 'go_to_eof': True, + 'line_numbers': False, + }), + ('help', + { + 'enable': True, + 'max_history_entries': 20, + 'wrap': True, + 'connect/editor': False, + 'connect/ipython_console': False, + 'math': True, + 'automatic_import': True, + 'plain_mode': False, + 'rich_mode': True, + 'show_source': False, + 'locked': False, + }), + ('onlinehelp', + { + 'enable': True, + 'zoom_factor': .8, + 'handle_links': False, + 'max_history_entries': 20, + }), + ('outline_explorer', + { + 'enable': True, + 'show_fullpath': False, + 'show_all_files': False, + 'group_cells': True, + 'sort_files_alphabetically': False, + 'show_comments': True, + 'follow_cursor': True, + 'display_variables': False + }), + ('project_explorer', + { + 'name_filters': NAME_FILTERS, + 'show_all': True, + 'show_hscrollbar': True, + 'max_recent_projects': 10, + 'visible_if_project_open': True, + 'date_column': False, + 'single_click_to_open': False, + 'show_hidden': True, + 'size_column': False, + 'type_column': False, + 'date_column': False + }), + ('explorer', + { + 'enable': True, + 'name_filters': NAME_FILTERS, + 'show_hidden': False, + 'single_click_to_open': False, + 'size_column': False, + 'type_column': False, + 'date_column': True + }), + ('find_in_files', + { + 'enable': True, + 'supported_encodings': ["utf-8", "iso-8859-1", "cp1252"], + 'exclude': EXCLUDE_PATTERNS, + 'exclude_regexp': False, + 'search_text_regexp': False, + 'search_text': [''], + 'search_text_samples': [TASKS_PATTERN], + 'more_options': False, + 'case_sensitive': False, + 'exclude_case_sensitive': False, + 'max_results': 1000, + }), + ('breakpoints', + { + 'enable': True, + }), + ('completions', + { + 'enable': True, + 'kite_call_to_action': False, + 'enable_code_snippets': True, + 'completions_wait_for_ms': 200, + 'enabled_providers': {}, + 'provider_configuration': {}, + 'request_priorities': {} + }), + ('profiler', + { + 'enable': True, + }), + ('pylint', + { + 'enable': True, + 'history_filenames': [], + 'max_entries': 30, + 'project_dir': None, + }), + ('workingdir', + { + 'working_dir_adjusttocontents': False, + 'working_dir_history': 20, + 'console/use_project_or_home_directory': False, + 'console/use_cwd': True, + 'console/use_fixed_directory': False, + 'startup/use_project_or_home_directory': True, + 'startup/use_fixed_directory': False, + }), + ('tours', + { + 'enable': True, + 'show_tour_message': True, + }), + ('shortcuts', + { + # ---- Global ---- + # -- In app/spyder.py + '_/close pane': "Shift+Ctrl+F4", + '_/lock unlock panes': "Shift+Ctrl+F5", + '_/use next layout': "Shift+Alt+PgDown", + '_/use previous layout': "Shift+Alt+PgUp", + '_/maximize pane': "Ctrl+Alt+Shift+M", + '_/fullscreen mode': "F11", + '_/save current layout': "Shift+Alt+S", + '_/layout preferences': "Shift+Alt+P", + '_/spyder documentation': "F1", + '_/restart': "Shift+Alt+R", + '_/quit': "Ctrl+Q", + # -- In plugins/editor + '_/file switcher': 'Ctrl+P', + '_/symbol finder': 'Ctrl+Alt+P', + '_/debug': "Ctrl+F5", + '_/debug step over': "Ctrl+F10", + '_/debug continue': "Ctrl+F12", + '_/debug step into': "Ctrl+F11", + '_/debug step return': "Ctrl+Shift+F11", + '_/debug exit': "Ctrl+Shift+F12", + '_/run': "F5", + '_/configure': "Ctrl+F6", + '_/re-run last script': "F6", + # -- In plugins/init + '_/switch to help': "Ctrl+Shift+H", + '_/switch to outline_explorer': "Ctrl+Shift+O", + '_/switch to editor': "Ctrl+Shift+E", + '_/switch to historylog': "Ctrl+Shift+L", + '_/switch to onlinehelp': "Ctrl+Shift+D", + '_/switch to project_explorer': "Ctrl+Shift+P", + '_/switch to ipython_console': "Ctrl+Shift+I", + '_/switch to variable_explorer': "Ctrl+Shift+V", + '_/switch to find_in_files': "Ctrl+Shift+F", + '_/switch to explorer': "Ctrl+Shift+X", + '_/switch to plots': "Ctrl+Shift+G", + '_/switch to pylint': "Ctrl+Shift+C", + '_/switch to profiler': "Ctrl+Shift+R", + # -- In widgets/findreplace.py + 'find_replace/find text': "Ctrl+F", + 'find_replace/find next': "F3", + 'find_replace/find previous': "Shift+F3", + 'find_replace/replace text': "Ctrl+R", + 'find_replace/hide find and replace': "Escape", + # ---- Editor ---- + # -- In widgets/sourcecode/codeeditor.py + 'editor/code completion': CTRL+'+Space', + 'editor/duplicate line up': ( + "Ctrl+Alt+Up" if WIN else "Shift+Alt+Up"), + 'editor/duplicate line down': ( + "Ctrl+Alt+Down" if WIN else "Shift+Alt+Down"), + 'editor/delete line': 'Ctrl+D', + 'editor/transform to uppercase': 'Ctrl+Shift+U', + 'editor/transform to lowercase': 'Ctrl+U', + 'editor/indent': 'Ctrl+]', + 'editor/unindent': 'Ctrl+[', + 'editor/move line up': "Alt+Up", + 'editor/move line down': "Alt+Down", + 'editor/go to new line': "Ctrl+Shift+Return", + 'editor/go to definition': "Ctrl+G", + 'editor/toggle comment': "Ctrl+1", + 'editor/blockcomment': "Ctrl+4", + 'editor/unblockcomment': "Ctrl+5", + 'editor/start of line': "Meta+A", + 'editor/end of line': "Meta+E", + 'editor/previous line': "Meta+P", + 'editor/next line': "Meta+N", + 'editor/previous char': "Meta+B", + 'editor/next char': "Meta+F", + 'editor/previous word': "Ctrl+Left", + 'editor/next word': "Ctrl+Right", + 'editor/kill to line end': "Meta+K", + 'editor/kill to line start': "Meta+U", + 'editor/yank': 'Meta+Y', + 'editor/rotate kill ring': 'Shift+Meta+Y', + 'editor/kill previous word': 'Meta+Backspace', + 'editor/kill next word': 'Meta+D', + 'editor/start of document': 'Ctrl+Home', + 'editor/end of document': 'Ctrl+End', + 'editor/undo': 'Ctrl+Z', + 'editor/redo': 'Ctrl+Shift+Z', + 'editor/cut': 'Ctrl+X', + 'editor/copy': 'Ctrl+C', + 'editor/paste': 'Ctrl+V', + 'editor/delete': 'Del', + 'editor/select all': "Ctrl+A", + # -- In widgets/editor.py + 'editor/inspect current object': 'Ctrl+I', + 'editor/breakpoint': 'F12', + 'editor/conditional breakpoint': 'Shift+F12', + 'editor/run selection': "F9", + 'editor/run to line': 'Shift+F9', + 'editor/run from line': CTRL + '+F9', + 'editor/go to line': 'Ctrl+L', + 'editor/go to previous file': CTRL + '+Shift+Tab', + 'editor/go to next file': CTRL + '+Tab', + 'editor/cycle to previous file': 'Ctrl+PgUp', + 'editor/cycle to next file': 'Ctrl+PgDown', + 'editor/new file': "Ctrl+N", + 'editor/open last closed':"Ctrl+Shift+T", + 'editor/open file': "Ctrl+O", + 'editor/save file': "Ctrl+S", + 'editor/save all': "Ctrl+Alt+S", + 'editor/save as': 'Ctrl+Shift+S', + 'editor/close all': "Ctrl+Shift+W", + 'editor/last edit location': "Ctrl+Alt+Shift+Left", + 'editor/previous cursor position': "Alt+Left", + 'editor/next cursor position': "Alt+Right", + 'editor/previous warning': "Ctrl+Alt+Shift+,", + 'editor/next warning': "Ctrl+Alt+Shift+.", + 'editor/zoom in 1': "Ctrl++", + 'editor/zoom in 2': "Ctrl+=", + 'editor/zoom out': "Ctrl+-", + 'editor/zoom reset': "Ctrl+0", + 'editor/close file 1': "Ctrl+W", + 'editor/close file 2': "Ctrl+F4", + 'editor/run cell': CTRL + '+Return', + 'editor/run cell and advance': 'Shift+Return', + 'editor/debug cell': 'Alt+Shift+Return', + 'editor/go to next cell': 'Ctrl+Down', + 'editor/go to previous cell': 'Ctrl+Up', + 'editor/re-run last cell': 'Alt+Return', + 'editor/split vertically': "Ctrl+{", + 'editor/split horizontally': "Ctrl+_", + 'editor/close split panel': "Alt+Shift+W", + 'editor/docstring': "Ctrl+Alt+D", + 'editor/autoformatting': "Ctrl+Alt+I", + 'editor/show in external file explorer': '', + # -- In Breakpoints + '_/switch to breakpoints': "Ctrl+Shift+B", + # ---- Consoles (in widgets/shell) ---- + 'console/inspect current object': "Ctrl+I", + 'console/clear shell': "Ctrl+L", + 'console/clear line': "Shift+Escape", + # ---- In Pylint ---- + 'pylint/run analysis': "F8", + # ---- In Profiler ---- + 'profiler/run profiler': "F10", + # ---- In widgets/ipythonconsole/shell.py ---- + 'ipython_console/new tab': "Ctrl+T", + 'ipython_console/reset namespace': "Ctrl+Alt+R", + 'ipython_console/restart kernel': "Ctrl+.", + 'ipython_console/inspect current object': "Ctrl+I", + 'ipython_console/clear shell': "Ctrl+L", + 'ipython_console/clear line': "Shift+Escape", + 'ipython_console/enter array inline': "Ctrl+Alt+M", + 'ipython_console/enter array table': "Ctrl+M", + # ---- In widgets/arraybuider.py ---- + 'array_builder/enter array inline': "Ctrl+Alt+M", + 'array_builder/enter array table': "Ctrl+M", + # ---- In widgets/variableexplorer/arrayeditor.py ---- + 'variable_explorer/copy': 'Ctrl+C', + # ---- In widgets/variableexplorer/namespacebrowser.py ---- + 'variable_explorer/search': 'Ctrl+F', + 'variable_explorer/refresh': 'Ctrl+R', + # ---- In widgets/plots/figurebrowser.py ---- + 'plots/copy': 'Ctrl+C', + 'plots/previous figure': 'Ctrl+PgUp', + 'plots/next figure': 'Ctrl+PgDown', + 'plots/save': 'Ctrl+S', + 'plots/save all': 'Ctrl+Alt+S', + 'plots/close': 'Ctrl+W', + 'plots/close all': 'Ctrl+Shift+W', + 'plots/zoom in': "Ctrl++", + 'plots/zoom out': "Ctrl+-", + # ---- In widgets/explorer ---- + 'explorer/copy file': 'Ctrl+C', + 'explorer/paste file': 'Ctrl+V', + 'explorer/copy absolute path': 'Ctrl+Alt+C', + 'explorer/copy relative path': 'Ctrl+Alt+Shift+C', + # ---- In plugins/findinfiles/plugin ---- + 'find_in_files/find in files': 'Alt+Shift+F', + }), + ('appearance', APPEARANCE), + ] + + +NAME_MAP = { + # Empty container object means use the rest of defaults + 'spyder': [], + # Splitting these files makes sense for projects, we might as well + # apply the same split for the app global config + # These options change on spyder startup or are tied to a specific OS, + # not good for version control + 'transient': [ + ('main', [ + 'completion/size', + 'crash', + 'current_version', + 'historylog_filename', + 'spyder_pythonpath', + 'window/position', + 'window/prefs_dialog_size', + 'window/size', + 'window/state', + ] + ), + ('toolbar', [ + 'last_visible_toolbars', + ] + ), + ('editor', [ + 'autosave_mapping', + 'bookmarks', + 'filenames', + 'layout_settings', + 'recent_files', + 'splitter_state', + ] + ), + ('explorer', [ + 'file_associations', + ]), + ('find_in_files', [ + 'path_history' + 'search_text', + 'exclude_index', + 'search_in_index', + ] + ), + ('main_interpreter', [ + 'custom_interpreters_list', + 'custom_interpreter', + 'executable', + ] + ), + ('onlinehelp', [ + 'zoom_factor', + ] + ), + ('outline_explorer', [ + 'expanded_state', + 'scrollbar_position', + ], + ), + ('project_explorer', [ + 'current_project_path', + 'expanded_state', + 'recent_projects', + 'max_recent_projects', + 'scrollbar_position', + ] + ), + ('quick_layouts', []), # Empty list means use all options + ('run', [ + 'breakpoints', + 'configurations', + 'defaultconfiguration', + 'default/wdir/fixed_directory', + ] + ), + ('workingdir', [ + 'console/fixed_directory', + 'startup/fixed_directory', + ] + ), + ('pylint', [ + 'history_filenames', + ] + ), + ] +} + + +# ============================================================================= +# Config instance +# ============================================================================= +# IMPORTANT NOTES: +# 1. If you want to *change* the default value of a current option, you need to +# do a MINOR update in config version, e.g. from 3.0.0 to 3.1.0 +# 2. If you want to *remove* options that are no longer needed in our codebase, +# 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 = '70.4.0' diff --git a/spyder/config/manager.py b/spyder/config/manager.py index fdf94f168c3..fca88026148 100644 --- a/spyder/config/manager.py +++ b/spyder/config/manager.py @@ -1,669 +1,669 @@ -# -*- coding: utf-8 -*- -# -# Copyright © Spyder Project Contributors -# Licensed under the terms of the MIT License -# (see spyder/__init__.py for details) - -""" -Configuration manager providing access to user/site/project configuration. -""" - -# Standard library imports -import logging -import os -import os.path as osp -from typing import Any, Dict, Optional, Set -import weakref - -# Local imports -from spyder.api.utils import PrefixedTuple -from spyder.config.base import ( - _, get_conf_paths, get_conf_path, get_home_dir, reset_config_files) -from spyder.config.main import CONF_VERSION, DEFAULTS, NAME_MAP -from spyder.config.types import ConfigurationKey, ConfigurationObserver -from spyder.config.user import UserConfig, MultiUserConfig, NoDefault, cp -from spyder.utils.programs import check_version - - -logger = logging.getLogger(__name__) - -EXTRA_VALID_SHORTCUT_CONTEXTS = [ - '_', - 'array_builder', - 'console', - 'find_replace', -] - - -class ConfigurationManager(object): - """ - Configuration manager to provide access to user/site/project config. - """ - - def __init__(self, parent=None, active_project_callback=None, - conf_path=None): - """ - Configuration manager to provide access to user/site/project config. - """ - path = conf_path if conf_path else self.get_user_config_path() - if not osp.isdir(path): - os.makedirs(path) - - # Site configuration defines the system defaults if a file - # is found in the site location - conf_paths = get_conf_paths() - site_defaults = DEFAULTS - for conf_path in reversed(conf_paths): - conf_fpath = os.path.join(conf_path, 'spyder.ini') - if os.path.isfile(conf_fpath): - site_config = UserConfig( - 'spyder', - path=conf_path, - defaults=site_defaults, - load=False, - version=CONF_VERSION, - backup=False, - raw_mode=True, - remove_obsolete=False, - ) - site_defaults = site_config.to_list() - - self._parent = parent - self._active_project_callback = active_project_callback - self._user_config = MultiUserConfig( - NAME_MAP, - path=path, - defaults=site_defaults, - load=True, - version=CONF_VERSION, - backup=True, - raw_mode=True, - remove_obsolete=False, - ) - - # This is useful to know in order to execute certain operations when - # bumping CONF_VERSION - self.old_spyder_version = ( - self._user_config._configs_map['spyder']._old_version) - - # Store plugin configurations when CONF_FILE = True - self._plugin_configs = {} - - # TODO: To be implemented in following PR - self._project_configs = {} # Cache project configurations - - # Object observer map - # This dict maps from a configuration key (str/tuple) to a set - # of objects that should be notified on changes to the corresponding - # subscription key per section. The observer objects must be hashable. - # - # type: Dict[ConfigurationKey, Dict[str, Set[ConfigurationObserver]]] - self._observers = {} - - # Set of suscription keys per observer object - # This dict maps from a observer object to the set of configuration - # keys that the object is subscribed to per section. - # - # type: Dict[ConfigurationObserver, Dict[str, Set[ConfigurationKey]]] - self._observer_map_keys = weakref.WeakKeyDictionary() - - # Setup - self.remove_deprecated_config_locations() - - def unregister_plugin(self, plugin_instance): - conf_section = plugin_instance.CONF_SECTION - if conf_section in self._plugin_configs: - self._plugin_configs.pop(conf_section, None) - - def register_plugin(self, plugin_class): - """Register plugin configuration.""" - conf_section = plugin_class.CONF_SECTION - if plugin_class.CONF_FILE and conf_section: - path = self.get_plugin_config_path(conf_section) - version = plugin_class.CONF_VERSION - version = version if version else '0.0.0' - name_map = plugin_class._CONF_NAME_MAP - name_map = name_map if name_map else {'spyder': []} - defaults = plugin_class.CONF_DEFAULTS - - if conf_section in self._plugin_configs: - raise RuntimeError('A plugin with section "{}" already ' - 'exists!'.format(conf_section)) - - plugin_config = MultiUserConfig( - name_map, - path=path, - defaults=defaults, - load=True, - version=version, - backup=True, - raw_mode=True, - remove_obsolete=False, - external_plugin=True - ) - - # Recreate external plugin configs to deal with part two - # (the shortcut conflicts) of spyder-ide/spyder#11132 - if check_version(self.old_spyder_version, '54.0.0', '<'): - # Remove all previous .ini files - try: - plugin_config.cleanup() - except EnvironmentError: - pass - - # Recreate config - plugin_config = MultiUserConfig( - name_map, - path=path, - defaults=defaults, - load=True, - version=version, - backup=True, - raw_mode=True, - remove_obsolete=False, - external_plugin=True - ) - - self._plugin_configs[conf_section] = (plugin_class, plugin_config) - - def remove_deprecated_config_locations(self): - """Removing old .spyder.ini location.""" - old_location = osp.join(get_home_dir(), '.spyder.ini') - if osp.isfile(old_location): - os.remove(old_location) - - def get_active_conf(self, section=None): - """ - Return the active user or project configuration for plugin. - """ - # Add a check for shortcuts! - if section is None: - config = self._user_config - elif section in self._plugin_configs: - _, config = self._plugin_configs[section] - else: - # TODO: implement project configuration on the following PR - config = self._user_config - - return config - - def get_user_config_path(self): - """Return the user configuration path.""" - base_path = get_conf_path() - path = osp.join(base_path, 'config') - if not osp.isdir(path): - os.makedirs(path) - - return path - - def get_plugin_config_path(self, plugin_folder): - """Return the plugin configuration path.""" - base_path = get_conf_path() - path = osp.join(base_path, 'plugins') - if plugin_folder is None: - raise RuntimeError('Plugin needs to define `CONF_SECTION`!') - path = osp.join(base_path, 'plugins', plugin_folder) - if not osp.isdir(path): - os.makedirs(path) - - return path - - # --- Observer pattern - # ------------------------------------------------------------------------ - def observe_configuration(self, - observer: ConfigurationObserver, - section: str, - option: Optional[ConfigurationKey] = None): - """ - Register an `observer` object to listen for changes in the option - `option` on the configuration `section`. - - Parameters - ---------- - observer: ConfigurationObserver - Object that conforms to the `ConfigurationObserver` protocol. - section: str - Name of the configuration section that contains the option - :param:`option` - option: Optional[ConfigurationKey] - Name of the option on the configuration section :param:`section` - that the object is going to suscribe to. If None, the observer - will observe any changes on any of the options of the configuration - section. - """ - section_sets = self._observers.get(section, {}) - option = option if option is not None else '__section' - - option_set = section_sets.get(option, weakref.WeakSet()) - option_set |= {observer} - - section_sets[option] = option_set - self._observers[section] = section_sets - - observer_section_sets = self._observer_map_keys.get(observer, {}) - section_set = observer_section_sets.get(section, set({})) - section_set |= {option} - - observer_section_sets[section] = section_set - self._observer_map_keys[observer] = observer_section_sets - - def unobserve_configuration(self, - observer: ConfigurationObserver, - section: Optional[str] = None, - option: Optional[ConfigurationKey] = None): - """ - Remove an observer to prevent it to receive further changes - on the values of the option `option` of the configuration section - `section`. - - Parameters - ---------- - observer: ConfigurationObserver - Object that conforms to the `ConfigurationObserver` protocol. - section: Optional[str] - Name of the configuration section that contains the option - :param:`option`. If None, the observer is unregistered from all - options for all sections that it has registered to. - option: Optional[ConfigurationKey] - Name of the configuration option on the configuration - :param:`section` that the observer is going to be unsubscribed - from. If None, the observer is unregistered from all the options of - the section `section`. - """ - if observer not in self._observer_map_keys: - return - - observer_sections = self._observer_map_keys[observer] - if section is not None: - section_options = observer_sections[section] - section_observers = self._observers[section] - if option is None: - for option in section_options: - option_observers = section_observers[option] - option_observers.remove(observer) - observer_sections.pop(section) - else: - option_observers = section_observers[option] - option_observers.remove(observer) - else: - for section in observer_sections: - section_options = observer_sections[section] - section_observers = self._observers[section] - for option in section_options: - option_observers = section_observers[option] - option_observers.remove(observer) - self._observer_map_keys.pop(observer) - - def notify_all_observers(self): - """ - Notify all the observers subscribed to all the sections and options. - """ - for section in self._observers: - self.notify_section_all_observers(section) - - def notify_observers(self, - section: str, - option: ConfigurationKey, - recursive_notification: bool = True): - """ - Notify observers of a change in the option `option` of configuration - section `section`. - - Parameters - ---------- - section: str - Name of the configuration section whose option did changed. - option: ConfigurationKey - Name/Path to the option that did changed. - recursive_notification: bool - If True, all objects that observe all changes on the - configuration section and objects that observe partial tuple paths - are notified. For example if the option `opt` of section `sec` - changes, then the observers for section `sec` are notified. - Likewise, if the option `(a, b, c)` changes, then observers for - `(a, b, c)`, `(a, b)` and a are notified as well. - """ - if recursive_notification: - # Notify to section listeners - self._notify_section(section) - - if isinstance(option, tuple) and recursive_notification: - # Notify to partial tuple observers - # e.g., If the option is (a, b, c), observers subscribed to - # (a, b, c), (a, b) and a are notified - option_list = list(option) - while option_list != []: - tuple_option = tuple(option_list) - if len(option_list) == 1: - tuple_option = tuple_option[0] - - value = self.get(section, tuple_option) - self._notify_option(section, tuple_option, value) - option_list.pop(-1) - else: - if option == '__section': - self._notify_section(section) - else: - value = self.get(section, option) - self._notify_option(section, option, value) - - def _notify_option(self, section: str, option: ConfigurationKey, - value: Any): - section_observers = self._observers.get(section, {}) - option_observers = section_observers.get(option, set({})) - if len(option_observers) > 0: - logger.debug('Sending notification to observers of ' - f'{option} in configuration section {section}') - for observer in list(option_observers): - try: - observer.on_configuration_change(option, section, value) - except RuntimeError: - # Prevent errors when Qt Objects are destroyed - self.unobserve_configuration(observer) - - def _notify_section(self, section: str): - section_values = dict(self.items(section) or []) - self._notify_option(section, '__section', section_values) - - def notify_section_all_observers(self, section: str): - """Notify all the observers subscribed to any option of a section.""" - option_observers = self._observers[section] - section_prefix = PrefixedTuple() - # Notify section observers - CONF.notify_observers(section, '__section') - for option in option_observers: - if isinstance(option, tuple): - section_prefix.add_path(option) - else: - try: - self.notify_observers(section, option) - except cp.NoOptionError: - # Skip notification if the option/section does not exist. - # This prevents unexpected errors in the test suite. - pass - # Notify prefixed observers - for prefix in section_prefix: - try: - self.notify_observers(section, prefix) - except cp.NoOptionError: - # See above explanation. - pass - - # --- Projects - # ------------------------------------------------------------------------ - def register_config(self, root_path, config): - """ - Register configuration with `root_path`. - - Useful for registering project configurations as they are opened. - """ - if self.is_project_root(root_path): - if root_path not in self._project_configs: - self._project_configs[root_path] = config - else: - # Validate which are valid site config locations - self._site_config = config - - def get_active_project(self): - """Return the `root_path` of the current active project.""" - callback = self._active_project_callback - if self._active_project_callback: - return callback() - - def is_project_root(self, root_path): - """Check if `root_path` corresponds to a valid spyder project.""" - return False - - def get_project_config_path(self, project_root): - """Return the project configuration path.""" - path = osp.join(project_root, '.spyproj', 'config') - if not osp.isdir(path): - os.makedirs(path) - - # MultiUserConf/UserConf interface - # ------------------------------------------------------------------------ - def items(self, section): - """Return all the items option/values for the given section.""" - config = self.get_active_conf(section) - return config.items(section) - - def options(self, section): - """Return all the options for the given section.""" - config = self.get_active_conf(section) - return config.options(section) - - def get(self, section, option, default=NoDefault): - """ - Get an `option` on a given `section`. - - If section is None, the `option` is requested from default section. - """ - config = self.get_active_conf(section) - if isinstance(option, tuple) and len(option) == 1: - option = option[0] - - if isinstance(option, tuple): - base_option = option[0] - intermediate_options = option[1:-1] - last_option = option[-1] - - base_conf = config.get( - section=section, option=base_option, default={}) - next_ptr = base_conf - for opt in intermediate_options: - next_ptr = next_ptr.get(opt, {}) - - value = next_ptr.get(last_option, None) - if value is None: - value = default - if default is NoDefault: - raise cp.NoOptionError(option, section) - else: - value = config.get(section=section, option=option, default=default) - return value - - def set(self, section, option, value, verbose=False, save=True, - recursive_notification=True, notification=True): - """ - Set an `option` on a given `section`. - - If section is None, the `option` is added to the default section. - """ - original_option = option - if isinstance(option, tuple): - base_option = option[0] - intermediate_options = option[1:-1] - last_option = option[-1] - - base_conf = self.get(section, base_option, {}) - conf_ptr = base_conf - for opt in intermediate_options: - next_ptr = conf_ptr.get(opt, {}) - conf_ptr[opt] = next_ptr - conf_ptr = next_ptr - - conf_ptr[last_option] = value - value = base_conf - option = base_option - - config = self.get_active_conf(section) - config.set(section=section, option=option, value=value, - verbose=verbose, save=save) - if notification: - self.notify_observers( - section, original_option, recursive_notification) - - def get_default(self, section, option): - """ - Get Default value for a given `section` and `option`. - - This is useful for type checking in `get` method. - """ - config = self.get_active_conf(section) - if isinstance(option, tuple): - base_option = option[0] - intermediate_options = option[1:-1] - last_option = option[-1] - - base_default = config.get_default(section, base_option) - conf_ptr = base_default - for opt in intermediate_options: - conf_ptr = conf_ptr[opt] - - return conf_ptr[last_option] - - return config.get_default(section, option) - - def remove_section(self, section): - """Remove `section` and all options within it.""" - config = self.get_active_conf(section) - config.remove_section(section) - - def remove_option(self, section, option): - """Remove `option` from `section`.""" - config = self.get_active_conf(section) - if isinstance(option, tuple): - base_option = option[0] - intermediate_options = option[1:-1] - last_option = option[-1] - - base_conf = self.get(section, base_option) - conf_ptr = base_conf - for opt in intermediate_options: - conf_ptr = conf_ptr[opt] - conf_ptr.pop(last_option) - self.set(section, base_option) - self.notify_observers(section, base_option) - else: - config.remove_option(section, option) - - def reset_to_defaults(self, section=None, notification=True): - """Reset config to Default values.""" - config = self.get_active_conf(section) - config.reset_to_defaults(section=section) - if notification: - if section is not None: - self.notify_section_all_observers(section) - else: - self.notify_all_observers() - - # Shortcut configuration management - # ------------------------------------------------------------------------ - def _get_shortcut_config(self, context, plugin_name=None): - """ - Return the shortcut configuration for global or plugin configs. - - Context must be either '_' for global or the name of a plugin. - """ - context = context.lower() - config = self._user_config - - if plugin_name in self._plugin_configs: - plugin_class, config = self._plugin_configs[plugin_name] - - # Check if plugin has a separate file - if not plugin_class.CONF_FILE: - config = self._user_config - - elif context in self._plugin_configs: - plugin_class, config = self._plugin_configs[context] - - # Check if plugin has a separate file - if not plugin_class.CONF_FILE: - config = self._user_config - - elif context in (self._user_config.sections() - + EXTRA_VALID_SHORTCUT_CONTEXTS): - config = self._user_config - else: - raise ValueError(_("Shortcut context must match '_' or the " - "plugin `CONF_SECTION`!")) - - return config - - def get_shortcut(self, context, name, plugin_name=None): - """ - Get keyboard shortcut (key sequence string). - - Context must be either '_' for global or the name of a plugin. - """ - config = self._get_shortcut_config(context, plugin_name) - return config.get('shortcuts', context + '/' + name.lower()) - - def set_shortcut(self, context, name, keystr, plugin_name=None): - """ - Set keyboard shortcut (key sequence string). - - Context must be either '_' for global or the name of a plugin. - """ - config = self._get_shortcut_config(context, plugin_name) - config.set('shortcuts', context + '/' + name, keystr) - - def config_shortcut(self, action, context, name, parent): - """ - Create a Shortcut namedtuple for a widget. - - The data contained in this tuple will be registered in our shortcuts - preferences page. - """ - # We only import on demand to avoid loading Qt modules - from spyder.config.gui import _config_shortcut - - keystr = self.get_shortcut(context, name) - sc = _config_shortcut(action, context, name, keystr, parent) - return sc - - def iter_shortcuts(self): - """Iterate over keyboard shortcuts.""" - for context_name, keystr in self._user_config.items('shortcuts'): - if context_name == 'enable': - continue - - if 'additional_configuration' not in context_name: - context, name = context_name.split('/', 1) - yield context, name, keystr - - for _, (_, plugin_config) in self._plugin_configs.items(): - items = plugin_config.items('shortcuts') - if items: - for context_name, keystr in items: - context, name = context_name.split('/', 1) - yield context, name, keystr - - def reset_shortcuts(self): - """Reset keyboard shortcuts to default values.""" - self._user_config.reset_to_defaults(section='shortcuts') - for _, (_, plugin_config) in self._plugin_configs.items(): - # TODO: check if the section exists? - plugin_config.reset_to_defaults(section='shortcuts') - - -try: - CONF = ConfigurationManager() -except Exception: - from qtpy.QtWidgets import QApplication, QMessageBox - - # Check if there's an app already running - app = QApplication.instance() - - # Create app, if there's none, in order to display the message below. - # NOTE: Don't use the functions we have to create a QApplication here - # because they could import CONF at some point, which would make this - # fallback fail. - # See issue spyder-ide/spyder#17889 - if app is None: - app = QApplication(['Spyder']) - app.setApplicationName('Spyder') - - reset_reply = QMessageBox.critical( - None, 'Spyder', - _("There was an error while loading Spyder configuration options. " - "You need to reset them for Spyder to be able to launch.\n\n" - "Do you want to proceed?"), - QMessageBox.Yes, QMessageBox.No) - if reset_reply == QMessageBox.Yes: - reset_config_files() - QMessageBox.information( - None, 'Spyder', - _("Spyder configuration files resetted!")) - os._exit(0) +# -*- coding: utf-8 -*- +# +# Copyright © Spyder Project Contributors +# Licensed under the terms of the MIT License +# (see spyder/__init__.py for details) + +""" +Configuration manager providing access to user/site/project configuration. +""" + +# Standard library imports +import logging +import os +import os.path as osp +from typing import Any, Dict, Optional, Set +import weakref + +# Local imports +from spyder.api.utils import PrefixedTuple +from spyder.config.base import ( + _, get_conf_paths, get_conf_path, get_home_dir, reset_config_files) +from spyder.config.main import CONF_VERSION, DEFAULTS, NAME_MAP +from spyder.config.types import ConfigurationKey, ConfigurationObserver +from spyder.config.user import UserConfig, MultiUserConfig, NoDefault, cp +from spyder.utils.programs import check_version + + +logger = logging.getLogger(__name__) + +EXTRA_VALID_SHORTCUT_CONTEXTS = [ + '_', + 'array_builder', + 'console', + 'find_replace', +] + + +class ConfigurationManager(object): + """ + Configuration manager to provide access to user/site/project config. + """ + + def __init__(self, parent=None, active_project_callback=None, + conf_path=None): + """ + Configuration manager to provide access to user/site/project config. + """ + path = conf_path if conf_path else self.get_user_config_path() + if not osp.isdir(path): + os.makedirs(path) + + # Site configuration defines the system defaults if a file + # is found in the site location + conf_paths = get_conf_paths() + site_defaults = DEFAULTS + for conf_path in reversed(conf_paths): + conf_fpath = os.path.join(conf_path, 'spyder.ini') + if os.path.isfile(conf_fpath): + site_config = UserConfig( + 'spyder', + path=conf_path, + defaults=site_defaults, + load=False, + version=CONF_VERSION, + backup=False, + raw_mode=True, + remove_obsolete=False, + ) + site_defaults = site_config.to_list() + + self._parent = parent + self._active_project_callback = active_project_callback + self._user_config = MultiUserConfig( + NAME_MAP, + path=path, + defaults=site_defaults, + load=True, + version=CONF_VERSION, + backup=True, + raw_mode=True, + remove_obsolete=False, + ) + + # This is useful to know in order to execute certain operations when + # bumping CONF_VERSION + self.old_spyder_version = ( + self._user_config._configs_map['spyder']._old_version) + + # Store plugin configurations when CONF_FILE = True + self._plugin_configs = {} + + # TODO: To be implemented in following PR + self._project_configs = {} # Cache project configurations + + # Object observer map + # This dict maps from a configuration key (str/tuple) to a set + # of objects that should be notified on changes to the corresponding + # subscription key per section. The observer objects must be hashable. + # + # type: Dict[ConfigurationKey, Dict[str, Set[ConfigurationObserver]]] + self._observers = {} + + # Set of suscription keys per observer object + # This dict maps from a observer object to the set of configuration + # keys that the object is subscribed to per section. + # + # type: Dict[ConfigurationObserver, Dict[str, Set[ConfigurationKey]]] + self._observer_map_keys = weakref.WeakKeyDictionary() + + # Setup + self.remove_deprecated_config_locations() + + def unregister_plugin(self, plugin_instance): + conf_section = plugin_instance.CONF_SECTION + if conf_section in self._plugin_configs: + self._plugin_configs.pop(conf_section, None) + + def register_plugin(self, plugin_class): + """Register plugin configuration.""" + conf_section = plugin_class.CONF_SECTION + if plugin_class.CONF_FILE and conf_section: + path = self.get_plugin_config_path(conf_section) + version = plugin_class.CONF_VERSION + version = version if version else '0.0.0' + name_map = plugin_class._CONF_NAME_MAP + name_map = name_map if name_map else {'spyder': []} + defaults = plugin_class.CONF_DEFAULTS + + if conf_section in self._plugin_configs: + raise RuntimeError('A plugin with section "{}" already ' + 'exists!'.format(conf_section)) + + plugin_config = MultiUserConfig( + name_map, + path=path, + defaults=defaults, + load=True, + version=version, + backup=True, + raw_mode=True, + remove_obsolete=False, + external_plugin=True + ) + + # Recreate external plugin configs to deal with part two + # (the shortcut conflicts) of spyder-ide/spyder#11132 + if check_version(self.old_spyder_version, '54.0.0', '<'): + # Remove all previous .ini files + try: + plugin_config.cleanup() + except EnvironmentError: + pass + + # Recreate config + plugin_config = MultiUserConfig( + name_map, + path=path, + defaults=defaults, + load=True, + version=version, + backup=True, + raw_mode=True, + remove_obsolete=False, + external_plugin=True + ) + + self._plugin_configs[conf_section] = (plugin_class, plugin_config) + + def remove_deprecated_config_locations(self): + """Removing old .spyder.ini location.""" + old_location = osp.join(get_home_dir(), '.spyder.ini') + if osp.isfile(old_location): + os.remove(old_location) + + def get_active_conf(self, section=None): + """ + Return the active user or project configuration for plugin. + """ + # Add a check for shortcuts! + if section is None: + config = self._user_config + elif section in self._plugin_configs: + _, config = self._plugin_configs[section] + else: + # TODO: implement project configuration on the following PR + config = self._user_config + + return config + + def get_user_config_path(self): + """Return the user configuration path.""" + base_path = get_conf_path() + path = osp.join(base_path, 'config') + if not osp.isdir(path): + os.makedirs(path) + + return path + + def get_plugin_config_path(self, plugin_folder): + """Return the plugin configuration path.""" + base_path = get_conf_path() + path = osp.join(base_path, 'plugins') + if plugin_folder is None: + raise RuntimeError('Plugin needs to define `CONF_SECTION`!') + path = osp.join(base_path, 'plugins', plugin_folder) + if not osp.isdir(path): + os.makedirs(path) + + return path + + # --- Observer pattern + # ------------------------------------------------------------------------ + def observe_configuration(self, + observer: ConfigurationObserver, + section: str, + option: Optional[ConfigurationKey] = None): + """ + Register an `observer` object to listen for changes in the option + `option` on the configuration `section`. + + Parameters + ---------- + observer: ConfigurationObserver + Object that conforms to the `ConfigurationObserver` protocol. + section: str + Name of the configuration section that contains the option + :param:`option` + option: Optional[ConfigurationKey] + Name of the option on the configuration section :param:`section` + that the object is going to suscribe to. If None, the observer + will observe any changes on any of the options of the configuration + section. + """ + section_sets = self._observers.get(section, {}) + option = option if option is not None else '__section' + + option_set = section_sets.get(option, weakref.WeakSet()) + option_set |= {observer} + + section_sets[option] = option_set + self._observers[section] = section_sets + + observer_section_sets = self._observer_map_keys.get(observer, {}) + section_set = observer_section_sets.get(section, set({})) + section_set |= {option} + + observer_section_sets[section] = section_set + self._observer_map_keys[observer] = observer_section_sets + + def unobserve_configuration(self, + observer: ConfigurationObserver, + section: Optional[str] = None, + option: Optional[ConfigurationKey] = None): + """ + Remove an observer to prevent it to receive further changes + on the values of the option `option` of the configuration section + `section`. + + Parameters + ---------- + observer: ConfigurationObserver + Object that conforms to the `ConfigurationObserver` protocol. + section: Optional[str] + Name of the configuration section that contains the option + :param:`option`. If None, the observer is unregistered from all + options for all sections that it has registered to. + option: Optional[ConfigurationKey] + Name of the configuration option on the configuration + :param:`section` that the observer is going to be unsubscribed + from. If None, the observer is unregistered from all the options of + the section `section`. + """ + if observer not in self._observer_map_keys: + return + + observer_sections = self._observer_map_keys[observer] + if section is not None: + section_options = observer_sections[section] + section_observers = self._observers[section] + if option is None: + for option in section_options: + option_observers = section_observers[option] + option_observers.remove(observer) + observer_sections.pop(section) + else: + option_observers = section_observers[option] + option_observers.remove(observer) + else: + for section in observer_sections: + section_options = observer_sections[section] + section_observers = self._observers[section] + for option in section_options: + option_observers = section_observers[option] + option_observers.remove(observer) + self._observer_map_keys.pop(observer) + + def notify_all_observers(self): + """ + Notify all the observers subscribed to all the sections and options. + """ + for section in self._observers: + self.notify_section_all_observers(section) + + def notify_observers(self, + section: str, + option: ConfigurationKey, + recursive_notification: bool = True): + """ + Notify observers of a change in the option `option` of configuration + section `section`. + + Parameters + ---------- + section: str + Name of the configuration section whose option did changed. + option: ConfigurationKey + Name/Path to the option that did changed. + recursive_notification: bool + If True, all objects that observe all changes on the + configuration section and objects that observe partial tuple paths + are notified. For example if the option `opt` of section `sec` + changes, then the observers for section `sec` are notified. + Likewise, if the option `(a, b, c)` changes, then observers for + `(a, b, c)`, `(a, b)` and a are notified as well. + """ + if recursive_notification: + # Notify to section listeners + self._notify_section(section) + + if isinstance(option, tuple) and recursive_notification: + # Notify to partial tuple observers + # e.g., If the option is (a, b, c), observers subscribed to + # (a, b, c), (a, b) and a are notified + option_list = list(option) + while option_list != []: + tuple_option = tuple(option_list) + if len(option_list) == 1: + tuple_option = tuple_option[0] + + value = self.get(section, tuple_option) + self._notify_option(section, tuple_option, value) + option_list.pop(-1) + else: + if option == '__section': + self._notify_section(section) + else: + value = self.get(section, option) + self._notify_option(section, option, value) + + def _notify_option(self, section: str, option: ConfigurationKey, + value: Any): + section_observers = self._observers.get(section, {}) + option_observers = section_observers.get(option, set({})) + if len(option_observers) > 0: + logger.debug('Sending notification to observers of ' + f'{option} in configuration section {section}') + for observer in list(option_observers): + try: + observer.on_configuration_change(option, section, value) + except RuntimeError: + # Prevent errors when Qt Objects are destroyed + self.unobserve_configuration(observer) + + def _notify_section(self, section: str): + section_values = dict(self.items(section) or []) + self._notify_option(section, '__section', section_values) + + def notify_section_all_observers(self, section: str): + """Notify all the observers subscribed to any option of a section.""" + option_observers = self._observers[section] + section_prefix = PrefixedTuple() + # Notify section observers + CONF.notify_observers(section, '__section') + for option in option_observers: + if isinstance(option, tuple): + section_prefix.add_path(option) + else: + try: + self.notify_observers(section, option) + except cp.NoOptionError: + # Skip notification if the option/section does not exist. + # This prevents unexpected errors in the test suite. + pass + # Notify prefixed observers + for prefix in section_prefix: + try: + self.notify_observers(section, prefix) + except cp.NoOptionError: + # See above explanation. + pass + + # --- Projects + # ------------------------------------------------------------------------ + def register_config(self, root_path, config): + """ + Register configuration with `root_path`. + + Useful for registering project configurations as they are opened. + """ + if self.is_project_root(root_path): + if root_path not in self._project_configs: + self._project_configs[root_path] = config + else: + # Validate which are valid site config locations + self._site_config = config + + def get_active_project(self): + """Return the `root_path` of the current active project.""" + callback = self._active_project_callback + if self._active_project_callback: + return callback() + + def is_project_root(self, root_path): + """Check if `root_path` corresponds to a valid spyder project.""" + return False + + def get_project_config_path(self, project_root): + """Return the project configuration path.""" + path = osp.join(project_root, '.spyproj', 'config') + if not osp.isdir(path): + os.makedirs(path) + + # MultiUserConf/UserConf interface + # ------------------------------------------------------------------------ + def items(self, section): + """Return all the items option/values for the given section.""" + config = self.get_active_conf(section) + return config.items(section) + + def options(self, section): + """Return all the options for the given section.""" + config = self.get_active_conf(section) + return config.options(section) + + def get(self, section, option, default=NoDefault): + """ + Get an `option` on a given `section`. + + If section is None, the `option` is requested from default section. + """ + config = self.get_active_conf(section) + if isinstance(option, tuple) and len(option) == 1: + option = option[0] + + if isinstance(option, tuple): + base_option = option[0] + intermediate_options = option[1:-1] + last_option = option[-1] + + base_conf = config.get( + section=section, option=base_option, default={}) + next_ptr = base_conf + for opt in intermediate_options: + next_ptr = next_ptr.get(opt, {}) + + value = next_ptr.get(last_option, None) + if value is None: + value = default + if default is NoDefault: + raise cp.NoOptionError(option, section) + else: + value = config.get(section=section, option=option, default=default) + return value + + def set(self, section, option, value, verbose=False, save=True, + recursive_notification=True, notification=True): + """ + Set an `option` on a given `section`. + + If section is None, the `option` is added to the default section. + """ + original_option = option + if isinstance(option, tuple): + base_option = option[0] + intermediate_options = option[1:-1] + last_option = option[-1] + + base_conf = self.get(section, base_option, {}) + conf_ptr = base_conf + for opt in intermediate_options: + next_ptr = conf_ptr.get(opt, {}) + conf_ptr[opt] = next_ptr + conf_ptr = next_ptr + + conf_ptr[last_option] = value + value = base_conf + option = base_option + + config = self.get_active_conf(section) + config.set(section=section, option=option, value=value, + verbose=verbose, save=save) + if notification: + self.notify_observers( + section, original_option, recursive_notification) + + def get_default(self, section, option): + """ + Get Default value for a given `section` and `option`. + + This is useful for type checking in `get` method. + """ + config = self.get_active_conf(section) + if isinstance(option, tuple): + base_option = option[0] + intermediate_options = option[1:-1] + last_option = option[-1] + + base_default = config.get_default(section, base_option) + conf_ptr = base_default + for opt in intermediate_options: + conf_ptr = conf_ptr[opt] + + return conf_ptr[last_option] + + return config.get_default(section, option) + + def remove_section(self, section): + """Remove `section` and all options within it.""" + config = self.get_active_conf(section) + config.remove_section(section) + + def remove_option(self, section, option): + """Remove `option` from `section`.""" + config = self.get_active_conf(section) + if isinstance(option, tuple): + base_option = option[0] + intermediate_options = option[1:-1] + last_option = option[-1] + + base_conf = self.get(section, base_option) + conf_ptr = base_conf + for opt in intermediate_options: + conf_ptr = conf_ptr[opt] + conf_ptr.pop(last_option) + self.set(section, base_option) + self.notify_observers(section, base_option) + else: + config.remove_option(section, option) + + def reset_to_defaults(self, section=None, notification=True): + """Reset config to Default values.""" + config = self.get_active_conf(section) + config.reset_to_defaults(section=section) + if notification: + if section is not None: + self.notify_section_all_observers(section) + else: + self.notify_all_observers() + + # Shortcut configuration management + # ------------------------------------------------------------------------ + def _get_shortcut_config(self, context, plugin_name=None): + """ + Return the shortcut configuration for global or plugin configs. + + Context must be either '_' for global or the name of a plugin. + """ + context = context.lower() + config = self._user_config + + if plugin_name in self._plugin_configs: + plugin_class, config = self._plugin_configs[plugin_name] + + # Check if plugin has a separate file + if not plugin_class.CONF_FILE: + config = self._user_config + + elif context in self._plugin_configs: + plugin_class, config = self._plugin_configs[context] + + # Check if plugin has a separate file + if not plugin_class.CONF_FILE: + config = self._user_config + + elif context in (self._user_config.sections() + + EXTRA_VALID_SHORTCUT_CONTEXTS): + config = self._user_config + else: + raise ValueError(_("Shortcut context must match '_' or the " + "plugin `CONF_SECTION`!")) + + return config + + def get_shortcut(self, context, name, plugin_name=None): + """ + Get keyboard shortcut (key sequence string). + + Context must be either '_' for global or the name of a plugin. + """ + config = self._get_shortcut_config(context, plugin_name) + return config.get('shortcuts', context + '/' + name.lower()) + + def set_shortcut(self, context, name, keystr, plugin_name=None): + """ + Set keyboard shortcut (key sequence string). + + Context must be either '_' for global or the name of a plugin. + """ + config = self._get_shortcut_config(context, plugin_name) + config.set('shortcuts', context + '/' + name, keystr) + + def config_shortcut(self, action, context, name, parent): + """ + Create a Shortcut namedtuple for a widget. + + The data contained in this tuple will be registered in our shortcuts + preferences page. + """ + # We only import on demand to avoid loading Qt modules + from spyder.config.gui import _config_shortcut + + keystr = self.get_shortcut(context, name) + sc = _config_shortcut(action, context, name, keystr, parent) + return sc + + def iter_shortcuts(self): + """Iterate over keyboard shortcuts.""" + for context_name, keystr in self._user_config.items('shortcuts'): + if context_name == 'enable': + continue + + if 'additional_configuration' not in context_name: + context, name = context_name.split('/', 1) + yield context, name, keystr + + for _, (_, plugin_config) in self._plugin_configs.items(): + items = plugin_config.items('shortcuts') + if items: + for context_name, keystr in items: + context, name = context_name.split('/', 1) + yield context, name, keystr + + def reset_shortcuts(self): + """Reset keyboard shortcuts to default values.""" + self._user_config.reset_to_defaults(section='shortcuts') + for _, (_, plugin_config) in self._plugin_configs.items(): + # TODO: check if the section exists? + plugin_config.reset_to_defaults(section='shortcuts') + + +try: + CONF = ConfigurationManager() +except Exception: + from qtpy.QtWidgets import QApplication, QMessageBox + + # Check if there's an app already running + app = QApplication.instance() + + # Create app, if there's none, in order to display the message below. + # NOTE: Don't use the functions we have to create a QApplication here + # because they could import CONF at some point, which would make this + # fallback fail. + # See issue spyder-ide/spyder#17889 + if app is None: + app = QApplication(['Spyder']) + app.setApplicationName('Spyder') + + reset_reply = QMessageBox.critical( + None, 'Spyder', + _("There was an error while loading Spyder configuration options. " + "You need to reset them for Spyder to be able to launch.\n\n" + "Do you want to proceed?"), + QMessageBox.Yes, QMessageBox.No) + if reset_reply == QMessageBox.Yes: + reset_config_files() + QMessageBox.information( + None, 'Spyder', + _("Spyder configuration files resetted!")) + os._exit(0) diff --git a/spyder/config/user.py b/spyder/config/user.py index 1ba215264c6..69ec15be297 100644 --- a/spyder/config/user.py +++ b/spyder/config/user.py @@ -1,1021 +1,1021 @@ -# -*- coding: utf-8 -*- -# -# Copyright © Spyder Project Contributors -# Licensed under the terms of the MIT License -# (see spyder/__init__.py for details) - -""" -This module provides user configuration file management features for Spyder. - -It is based on the ConfigParser module present in the standard library. -""" - -from __future__ import print_function, unicode_literals - -# Standard library imports -import ast -import copy -import io -import os -import os.path as osp -import re -import shutil -import time - -# Local imports -from spyder.config.base import get_conf_path, get_module_source_path -from spyder.py3compat import configparser as cp -from spyder.py3compat import is_text_string, PY2, to_text_string -from spyder.utils.programs import check_version - - -# ============================================================================ -# Auxiliary classes -# ============================================================================ -class NoDefault: - pass - - -# ============================================================================ -# Defaults class -# ============================================================================ -class DefaultsConfig(cp.ConfigParser, object): - """ - Class used to save defaults to a file and as UserConfig base class. - """ - - def __init__(self, name, path): - """ - Class used to save defaults to a file and as UserConfig base class. - """ - if PY2: - super(DefaultsConfig, self).__init__() - else: - super(DefaultsConfig, self).__init__(interpolation=None) - - self._name = name - self._path = path - - if not osp.isdir(osp.dirname(self._path)): - os.makedirs(osp.dirname(self._path)) - - def _write(self, fp): - """ - Write method for Python 2. - - The one from configparser fails for non-ascii Windows accounts. - """ - if self._defaults: - fp.write('[{}]\n'.format(cp.DEFAULTSECT)) - for (key, value) in self._defaults.items(): - value_plus_end_of_line = str(value).replace('\n', '\n\t') - fp.write('{} = {}\n'.format(key, value_plus_end_of_line)) - - fp.write('\n') - - for section in self._sections: - fp.write('[{}]\n'.format(section)) - for (key, value) in self._sections[section].items(): - if key == '__name__': - continue - - if (value is not None) or (self._optcre == self.OPTCRE): - value = to_text_string(value) - value_plus_end_of_line = value.replace('\n', '\n\t') - key = ' = '.join((key, value_plus_end_of_line)) - - fp.write('{}\n'.format(key)) - - fp.write('\n') - - def _set(self, section, option, value, verbose): - """Set method.""" - if not self.has_section(section): - self.add_section(section) - - if not is_text_string(value): - value = repr(value) - - if verbose: - text = '[{}][{}] = {}'.format(section, option, value) - print(text) # spyder: test-skip - - super(DefaultsConfig, self).set(section, option, value) - - def _save(self): - """Save config into the associated .ini file.""" - fpath = self.get_config_fpath() - - def _write_file(fpath): - with io.open(fpath, 'w', encoding='utf-8') as configfile: - if PY2: - self._write(configfile) - else: - self.write(configfile) - - # See spyder-ide/spyder#1086 and spyder-ide/spyder#1242 for background - # on why this method contains all the exception handling. - try: - # The "easy" way - _write_file(fpath) - except EnvironmentError: - try: - # The "delete and sleep" way - if osp.isfile(fpath): - os.remove(fpath) - - time.sleep(0.05) - _write_file(fpath) - except Exception as e: - print('Failed to write user configuration file to disk, with ' - 'the exception shown below') # spyder: test-skip - print(e) # spyder: test-skip - - def get_config_fpath(self): - """Return the ini file where this configuration is stored.""" - path = self._path - config_file = osp.join(path, '{}.ini'.format(self._name)) - return config_file - - def set_defaults(self, defaults): - """Set default values and save to defaults folder location.""" - for section, options in defaults: - for option in options: - new_value = options[option] - self._set(section, option, new_value, verbose=False) - - -# ============================================================================ -# User config class -# ============================================================================ -class UserConfig(DefaultsConfig): - """ - UserConfig class, based on ConfigParser. - - Parameters - ---------- - name: str - Name of the config - path: str - Configuration file will be saved in path/%name%.ini - defaults: {} or [(str, {}),] - Dictionary containing options *or* list of tuples (sec_name, options) - load: bool - If a previous configuration file is found, load will take the values - from this existing file, instead of using default values. - version: str - version of the configuration file in 'major.minor.micro' format. - backup: bool - A backup will be created on version changes and on initial setup. - raw_mode: bool - If `True` do not apply any automatic conversion on values read from - the configuration. - remove_obsolete: bool - If `True`, values that were removed from the configuration on version - change, are removed from the saved configuration file. - - Notes - ----- - The 'get' and 'set' arguments number and type differ from the overriden - methods. 'defaults' is an attribute and not a method. - """ - DEFAULT_SECTION_NAME = 'main' - - def __init__(self, name, path, defaults=None, load=True, version=None, - backup=False, raw_mode=False, remove_obsolete=False, - external_plugin=False): - """UserConfig class, based on ConfigParser.""" - super(UserConfig, self).__init__(name=name, path=path) - - self._load = load - self._version = self._check_version(version) - self._backup = backup - self._raw = 1 if raw_mode else 0 - self._remove_obsolete = remove_obsolete - self._external_plugin = external_plugin - - self._module_source_path = get_module_source_path('spyder') - self._defaults_folder = 'defaults' - self._backup_folder = 'backups' - self._backup_suffix = '.bak' - self._defaults_name_prefix = 'defaults' - - # This attribute is overriding a method from cp.ConfigParser - self.defaults = self._check_defaults(defaults) - - if backup: - self._make_backup() - - if load: - # If config file already exists, it overrides Default options - previous_fpath = self.get_previous_config_fpath() - self._load_from_ini(previous_fpath) - old_version = self.get_version(version) - self._old_version = old_version - - # Save new defaults - self._save_new_defaults(self.defaults) - - # Updating defaults only if major/minor version is different - if (self._get_minor_version(version) - != self._get_minor_version(old_version)): - - if backup: - self._make_backup(version=old_version) - - self.apply_configuration_patches(old_version=old_version) - - # Remove deprecated options if major version has changed - if remove_obsolete: - self._remove_deprecated_options(old_version) - - # Set new version number - self.set_version(version, save=False) - - if defaults is None: - # If no defaults are defined set .ini file settings as default - self.set_as_defaults() - - # --- Helpers and checkers - # ------------------------------------------------------------------------ - @staticmethod - def _get_minor_version(version): - """Return the 'major.minor' components of the version.""" - return version[:version.rfind('.')] - - @staticmethod - def _get_major_version(version): - """Return the 'major' component of the version.""" - return version[:version.find('.')] - - @staticmethod - def _check_version(version): - """Check version is compliant with format.""" - regex_check = re.match(r'^(\d+).(\d+).(\d+)$', version) - if version is not None and regex_check is None: - raise ValueError('Version number {} is incorrect - must be in ' - 'major.minor.micro format'.format(version)) - - return version - - def _check_defaults(self, defaults): - """Check if defaults are valid and update defaults values.""" - if defaults is None: - defaults = [(self.DEFAULT_SECTION_NAME, {})] - elif isinstance(defaults, dict): - defaults = [(self.DEFAULT_SECTION_NAME, defaults)] - elif isinstance(defaults, list): - # Check is a list of tuples with strings and dictionaries - for sec, options in defaults: - assert is_text_string(sec) - assert isinstance(options, dict) - for opt, _ in options.items(): - assert is_text_string(opt) - else: - raise ValueError('`defaults` must be a dict or a list of tuples!') - - # This attribute is overriding a method from cp.ConfigParser - self.defaults = defaults - - if defaults is not None: - self.reset_to_defaults(save=False) - - return defaults - - @classmethod - def _check_section_option(cls, section, option): - """Check section and option types.""" - if section is None: - section = cls.DEFAULT_SECTION_NAME - elif not is_text_string(section): - raise RuntimeError("Argument 'section' must be a string") - - if not is_text_string(option): - raise RuntimeError("Argument 'option' must be a string") - - return section - - def _make_backup(self, version=None, old_version=None): - """ - Make a backup of the configuration file. - - If `old_version` is `None` a normal backup is made. If `old_version` - is provided, then the backup was requested for minor version changes - and appends the version number to the backup file. - """ - fpath = self.get_config_fpath() - fpath_backup = self.get_backup_fpath_from_version( - version=version, old_version=old_version) - path = os.path.dirname(fpath_backup) - - if not osp.isdir(path): - os.makedirs(path) - - try: - shutil.copyfile(fpath, fpath_backup) - except IOError: - pass - - def _load_from_ini(self, fpath): - """Load config from the associated .ini file found at `fpath`.""" - try: - if PY2: - # Python 2 - if osp.isfile(fpath): - try: - with io.open(fpath, encoding='utf-8') as configfile: - self.readfp(configfile) - except IOError: - error_text = "Failed reading file", fpath - print(error_text) # spyder: test-skip - else: - # Python 3 - self.read(fpath, encoding='utf-8') - except cp.MissingSectionHeaderError: - error_text = 'Warning: File contains no section headers.' - print(error_text) # spyder: test-skip - - def _load_old_defaults(self, old_version): - """Read old defaults.""" - old_defaults = cp.ConfigParser() - path, name = self.get_defaults_path_name_from_version(old_version) - old_defaults.read(osp.join(path, name + '.ini')) - return old_defaults - - def _save_new_defaults(self, defaults): - """Save new defaults.""" - path, name = self.get_defaults_path_name_from_version() - new_defaults = DefaultsConfig(name=name, path=path) - if not osp.isfile(new_defaults.get_config_fpath()): - new_defaults.set_defaults(defaults) - new_defaults._save() - - def _update_defaults(self, defaults, old_version, verbose=False): - """Update defaults after a change in version.""" - old_defaults = self._load_old_defaults(old_version) - for section, options in defaults: - for option in options: - new_value = options[option] - try: - old_val = old_defaults.get(section, option) - except (cp.NoSectionError, cp.NoOptionError): - old_val = None - - if old_val is None or to_text_string(new_value) != old_val: - self._set(section, option, new_value, verbose) - - def _remove_deprecated_options(self, old_version): - """ - Remove options which are present in the .ini file but not in defaults. - """ - old_defaults = self._load_old_defaults(old_version) - for section in old_defaults.sections(): - for option, _ in old_defaults.items(section, raw=self._raw): - if self.get_default(section, option) is NoDefault: - try: - self.remove_option(section, option) - if len(self.items(section, raw=self._raw)) == 0: - self.remove_section(section) - except cp.NoSectionError: - self.remove_section(section) - - # --- Compatibility API - # ------------------------------------------------------------------------ - def get_previous_config_fpath(self): - """Return the last configuration file used if found.""" - return self.get_config_fpath() - - def get_config_fpath_from_version(self, version=None): - """ - Return the configuration path for given version. - - If no version is provided, it returns the current file path. - """ - return self.get_config_fpath() - - def get_backup_fpath_from_version(self, version=None, old_version=None): - """ - Get backup location based on version. - - `old_version` can be used for checking compatibility whereas `version` - relates to adding the version to the file name. - - To be overridden if versions changed backup location. - """ - fpath = self.get_config_fpath() - path = osp.join(osp.dirname(fpath), self._backup_folder) - new_fpath = osp.join(path, osp.basename(fpath)) - if version is None: - backup_fpath = '{}{}'.format(new_fpath, self._backup_suffix) - else: - backup_fpath = "{}-{}{}".format(new_fpath, version, - self._backup_suffix) - return backup_fpath - - def get_defaults_path_name_from_version(self, old_version=None): - """ - Get defaults location based on version. - - To be overridden if versions changed defaults location. - """ - version = old_version if old_version else self._version - defaults_path = osp.join(osp.dirname(self.get_config_fpath()), - self._defaults_folder) - name = '{}-{}-{}'.format( - self._defaults_name_prefix, - self._name, - version, - ) - if not osp.isdir(defaults_path): - os.makedirs(defaults_path) - - return defaults_path, name - - def apply_configuration_patches(self, old_version=None): - """ - Apply any patch to configuration values on version changes. - - To be overridden if patches to configuration values are needed. - """ - pass - - # --- Public API - # ------------------------------------------------------------------------ - def get_version(self, version='0.0.0'): - """Return configuration (not application!) version.""" - return self.get(self.DEFAULT_SECTION_NAME, 'version', version) - - def set_version(self, version='0.0.0', save=True): - """Set configuration (not application!) version.""" - version = self._check_version(version) - self.set(self.DEFAULT_SECTION_NAME, 'version', version, save=save) - - def reset_to_defaults(self, save=True, verbose=False, section=None): - """Reset config to Default values.""" - for sec, options in self.defaults: - if section == None or section == sec: - for option in options: - value = options[option] - self._set(sec, option, value, verbose) - if save: - self._save() - - def set_as_defaults(self): - """Set defaults from the current config.""" - self.defaults = [] - for section in self.sections(): - secdict = {} - for option, value in self.items(section, raw=self._raw): - try: - value = ast.literal_eval(value) - except (SyntaxError, ValueError): - pass - secdict[option] = value - self.defaults.append((section, secdict)) - - def get_default(self, section, option): - """ - Get default value for a given `section` and `option`. - - This is useful for type checking in `get` method. - """ - section = self._check_section_option(section, option) - for sec, options in self.defaults: - if sec == section: - if option in options: - value = options[option] - break - else: - value = NoDefault - - return value - - def get(self, section, option, default=NoDefault): - """ - Get an option. - - Parameters - ---------- - section: str - Section name. If `None` is provide use the default section name. - option: str - Option name for `section`. - default: - Default value (if not specified, an exception will be raised if - option doesn't exist). - """ - section = self._check_section_option(section, option) - - if not self.has_section(section): - if default is NoDefault: - raise cp.NoSectionError(section) - else: - self.add_section(section) - - if not self.has_option(section, option): - if default is NoDefault: - raise cp.NoOptionError(option, section) - else: - self.set(section, option, default) - return default - - value = super(UserConfig, self).get(section, option, raw=self._raw) - - default_value = self.get_default(section, option) - if isinstance(default_value, bool): - value = ast.literal_eval(value) - elif isinstance(default_value, float): - value = float(value) - elif isinstance(default_value, int): - value = int(value) - elif is_text_string(default_value): - if PY2: - try: - value = value.decode('utf-8') - try: - # Some str config values expect to be eval after - # decoding - new_value = ast.literal_eval(value) - if is_text_string(new_value): - value = new_value - except (SyntaxError, ValueError): - pass - except (UnicodeEncodeError, UnicodeDecodeError): - pass - else: - try: - # Lists, tuples, ... - value = ast.literal_eval(value) - except (SyntaxError, ValueError): - pass - - return value - - def set_default(self, section, option, default_value): - """ - Set Default value for a given `section`, `option`. - - If no defaults exist, no default is created. To be able to set - defaults, a call to set_as_defaults is needed to create defaults - based on current values. - """ - section = self._check_section_option(section, option) - for sec, options in self.defaults: - if sec == section: - options[option] = default_value - - def set(self, section, option, value, verbose=False, save=True): - """ - Set an `option` on a given `section`. - - If section is None, the `option` is added to the default section. - """ - section = self._check_section_option(section, option) - default_value = self.get_default(section, option) - - if default_value is NoDefault: - # This let us save correctly string value options with - # no config default that contain non-ascii chars in - # Python 2 - if PY2 and is_text_string(value): - value = repr(value) - - default_value = value - self.set_default(section, option, default_value) - - if isinstance(default_value, bool): - value = bool(value) - elif isinstance(default_value, float): - value = float(value) - elif isinstance(default_value, int): - value = int(value) - elif not is_text_string(default_value): - value = repr(value) - - self._set(section, option, value, verbose) - if save: - self._save() - - def remove_section(self, section): - """Remove `section` and all options within it.""" - super(UserConfig, self).remove_section(section) - self._save() - - def remove_option(self, section, option): - """Remove `option` from `section`.""" - super(UserConfig, self).remove_option(section, option) - self._save() - - def cleanup(self): - """Remove .ini file associated to config.""" - os.remove(self.get_config_fpath()) - - def to_list(self): - """ - Return in list format. - - The format is [('section1', {'opt-1': value, ...}), - ('section2', {'opt-2': othervalue, ...}), ...] - """ - new_defaults = [] - self._load_from_ini(self.get_config_fpath()) - for section in self._sections: - sec_data = {} - for (option, _) in self.items(section): - sec_data[option] = self.get(section, option) - new_defaults.append((section, sec_data)) - - return new_defaults - - -class SpyderUserConfig(UserConfig): - - def get_previous_config_fpath(self): - """ - Override method. - - Return the last configuration file used if found. - """ - fpath = self.get_config_fpath() - - # We don't need to add the contents of the old spyder.ini to - # the configuration of external plugins. This was the cause - # of part two (the shortcut conflicts) of issue - # spyder-ide/spyder#11132 - if self._external_plugin: - previous_paths = [fpath] - else: - previous_paths = [ - # >= 51.0.0 - fpath, - # < 51.0.0 - os.path.join(get_conf_path(), 'spyder.ini'), - ] - - for fpath in previous_paths: - if osp.isfile(fpath): - break - - return fpath - - def get_config_fpath_from_version(self, version=None): - """ - Override method. - - Return the configuration path for given version. - - If no version is provided, it returns the current file path. - """ - if version is None or self._external_plugin: - fpath = self.get_config_fpath() - elif check_version(version, '51.0.0', '<'): - fpath = osp.join(get_conf_path(), 'spyder.ini') - else: - fpath = self.get_config_fpath() - - return fpath - - def get_backup_fpath_from_version(self, version=None, old_version=None): - """ - Override method. - - Make a backup of the configuration file. - """ - if old_version and check_version(old_version, '51.0.0', '<'): - name = 'spyder.ini' - fpath = os.path.join(get_conf_path(), name) - if version is None: - backup_fpath = "{}{}".format(fpath, self._backup_suffix) - else: - backup_fpath = "{}-{}{}".format(fpath, version, - self._backup_suffix) - else: - super_class = super(SpyderUserConfig, self) - backup_fpath = super_class.get_backup_fpath_from_version( - version, old_version) - - return backup_fpath - - def get_defaults_path_name_from_version(self, old_version=None): - """ - Override method. - - Get defaults location based on version. - """ - if old_version: - if check_version(old_version, '51.0.0', '<'): - name = '{}-{}'.format(self._defaults_name_prefix, old_version) - path = osp.join(get_conf_path(), 'defaults') - else: - super_class = super(SpyderUserConfig, self) - path, name = super_class.get_defaults_path_name_from_version( - old_version) - else: - super_class = super(SpyderUserConfig, self) - path, name = super_class.get_defaults_path_name_from_version() - - return path, name - - def apply_configuration_patches(self, old_version=None): - """ - Override method. - - Apply any patch to configuration values on version changes. - """ - self._update_defaults(self.defaults, old_version) - - if self._external_plugin: - return - if old_version and check_version(old_version, '44.1.0', '<'): - run_lines = to_text_string(self.get('ipython_console', - 'startup/run_lines')) - if run_lines is not NoDefault: - run_lines = run_lines.replace(',', '; ') - self.set('ipython_console', 'startup/run_lines', run_lines) - - -class MultiUserConfig(object): - """ - Multiuser config class which emulates the basic UserConfig interface. - - This class provides the same basic interface as UserConfig but allows - splitting the configuration sections and options among several files. - - The `name` is now a `name_map` where the sections and options per file name - are defined. - """ - DEFAULT_FILE_NAME = 'spyder' - - def __init__(self, name_map, path, defaults=None, load=True, version=None, - backup=False, raw_mode=False, remove_obsolete=False, - external_plugin=False): - """Multi user config class based on UserConfig class.""" - self._name_map = self._check_name_map(name_map) - self._path = path - self._defaults = defaults - self._load = load - self._version = version - self._backup = backup - self._raw_mode = 1 if raw_mode else 0 - self._remove_obsolete = remove_obsolete - self._external_plugin = external_plugin - - self._configs_map = {} - self._config_defaults_map = self._get_defaults_for_name_map(defaults, - name_map) - self._config_kwargs = { - 'path': path, - 'defaults': defaults, - 'load': load, - 'version': version, - 'backup': backup, - 'raw_mode': raw_mode, - 'remove_obsolete': False, # This will be handled later on if True - 'external_plugin': external_plugin - } - - for name in name_map: - defaults = self._config_defaults_map.get(name) - mod_kwargs = { - 'name': name, - 'defaults': defaults, - } - new_kwargs = self._config_kwargs.copy() - new_kwargs.update(mod_kwargs) - config_class = self.get_config_class() - self._configs_map[name] = config_class(**new_kwargs) - - # Remove deprecated options if major version has changed - default_config = self._configs_map.get(self.DEFAULT_FILE_NAME) - major_ver = default_config._get_major_version(version) - major_old_ver = default_config._get_major_version( - default_config._old_version) - - # Now we can apply remove_obsolete - if remove_obsolete or major_ver != major_old_ver: - for _, config in self._configs_map.items(): - config._remove_deprecated_options(config._old_version) - - def _get_config(self, section, option): - """Get the correct configuration based on section and option.""" - # Check the filemap first - name = self._get_name_from_map(section, option) - config_value = self._configs_map.get(name, None) - - if config_value is None: - config_value = self._configs_map[self.DEFAULT_FILE_NAME] - return config_value - - def _check_name_map(self, name_map): - """Check `name_map` follows the correct format.""" - # Check section option paris are not repeated - sections_options = [] - for _, sec_opts in name_map.items(): - for section, options in sec_opts: - for option in options: - sec_opt = (section, option) - if sec_opt not in sections_options: - sections_options.append(sec_opt) - else: - error_msg = ( - 'Different files are holding the same ' - 'section/option: "{}/{}"!'.format(section, option) - ) - raise ValueError(error_msg) - return name_map - - @staticmethod - def _get_section_from_defaults(defaults, section): - """Get the section contents from the defaults.""" - for sec, options in defaults: - if section == sec: - value = options - break - else: - raise ValueError('section "{}" not found!'.format(section)) - - return value - - @staticmethod - def _get_option_from_defaults(defaults, section, option): - """Get the section,option value from the defaults.""" - value = NoDefault - for sec, options in defaults: - if section == sec: - value = options.get(option, NoDefault) - break - return value - - @staticmethod - def _remove_section_from_defaults(defaults, section): - """Remove section from defaults.""" - idx_remove = None - for idx, (sec, _) in enumerate(defaults): - if section == sec: - idx_remove = idx - break - - if idx_remove is not None: - defaults.pop(idx) - - @staticmethod - def _remove_option_from_defaults(defaults, section, option): - """Remove section,option from defaults.""" - for sec, options in defaults: - if section == sec: - if option in options: - options.pop(option) - break - - def _get_name_from_map(self, section=None, option=None): - """ - Search for section and option on the name_map and return the name. - """ - for name, sec_opts in self._name_map.items(): - # Ignore the main section - default_sec_name = self._configs_map.get(name).DEFAULT_SECTION_NAME - if name == default_sec_name: - continue - for sec, options in sec_opts: - if sec == section: - if len(options) == 0: - return name - else: - for opt in options: - if opt == option: - return name - - @classmethod - def _get_defaults_for_name_map(cls, defaults, name_map): - """Split the global defaults using the name_map.""" - name_map_config = {} - defaults_copy = copy.deepcopy(defaults) - - for name, sec_opts in name_map.items(): - default_map_for_name = [] - if len(sec_opts) == 0: - name_map_config[name] = defaults_copy - else: - for section, options in sec_opts: - if len(options) == 0: - # Use all on section - sec = cls._get_section_from_defaults(defaults_copy, - section) - - # Remove section from defaults - cls._remove_section_from_defaults(defaults_copy, - section) - - else: - # Iterate and pop! - sec = {} - for opt in options: - val = cls._get_option_from_defaults(defaults_copy, - section, opt) - if val is not NoDefault: - sec[opt] = val - - # Remove option from defaults - cls._remove_option_from_defaults(defaults_copy, - section, opt) - - # Add to config map - default_map_for_name.append((section, sec)) - - name_map_config[name] = default_map_for_name - - return name_map_config - - def get_config_class(self): - """Return the UserConfig class to use.""" - return SpyderUserConfig - - def sections(self): - """Return all sections of the configuration file.""" - sections = set() - for _, config in self._configs_map.items(): - for section in config.sections(): - sections.add(section) - - return list(sorted(sections)) - - def items(self, section): - """Return all the items option/values for the given section.""" - config = self._get_config(section, None) - if config is None: - config = self._configs_map[self.DEFAULT_FILE_NAME] - - if config.has_section(section): - return config.items(section=section) - else: - return None - - def options(self, section): - """Return all the options for the given section.""" - config = self._get_config(section, None) - return config.options(section=section) - - def get_default(self, section, option): - """ - Get Default value for a given `section` and `option`. - - This is useful for type checking in `get` method. - """ - config = self._get_config(section, option) - if config is None: - config = self._configs_map[self.DEFAULT_FILE_NAME] - return config.get_default(section, option) - - def get(self, section, option, default=NoDefault): - """ - Get an `option` on a given `section`. - - If section is None, the `option` is requested from default section. - """ - config = self._get_config(section, option) - - if config is None: - config = self._configs_map[self.DEFAULT_FILE_NAME] - - return config.get(section=section, option=option, default=default) - - def set(self, section, option, value, verbose=False, save=True): - """ - Set an `option` on a given `section`. - - If section is None, the `option` is added to the default section. - """ - config = self._get_config(section, option) - config.set(section=section, option=option, value=value, - verbose=verbose, save=save) - - def reset_to_defaults(self, section=None): - """Reset configuration to Default values.""" - for _, config in self._configs_map.items(): - config.reset_to_defaults(section=section) - - def remove_section(self, section): - """Remove `section` and all options within it.""" - config = self._get_config(section, None) - config.remove_section(section) - - def remove_option(self, section, option): - """Remove `option` from `section`.""" - config = self._get_config(section, option) - config.remove_option(section, option) - - def cleanup(self): - """Remove .ini files associated to configurations.""" - for _, config in self._configs_map.items(): - os.remove(config.get_config_fpath()) - - -class PluginConfig(UserConfig): - """Plugin configuration handler.""" - - -class PluginMultiConfig(MultiUserConfig): - """Plugin configuration handler with multifile support.""" - - def get_config_class(self): - return PluginConfig +# -*- coding: utf-8 -*- +# +# Copyright © Spyder Project Contributors +# Licensed under the terms of the MIT License +# (see spyder/__init__.py for details) + +""" +This module provides user configuration file management features for Spyder. + +It is based on the ConfigParser module present in the standard library. +""" + +from __future__ import print_function, unicode_literals + +# Standard library imports +import ast +import copy +import io +import os +import os.path as osp +import re +import shutil +import time + +# Local imports +from spyder.config.base import get_conf_path, get_module_source_path +from spyder.py3compat import configparser as cp +from spyder.py3compat import is_text_string, PY2, to_text_string +from spyder.utils.programs import check_version + + +# ============================================================================ +# Auxiliary classes +# ============================================================================ +class NoDefault: + pass + + +# ============================================================================ +# Defaults class +# ============================================================================ +class DefaultsConfig(cp.ConfigParser, object): + """ + Class used to save defaults to a file and as UserConfig base class. + """ + + def __init__(self, name, path): + """ + Class used to save defaults to a file and as UserConfig base class. + """ + if PY2: + super(DefaultsConfig, self).__init__() + else: + super(DefaultsConfig, self).__init__(interpolation=None) + + self._name = name + self._path = path + + if not osp.isdir(osp.dirname(self._path)): + os.makedirs(osp.dirname(self._path)) + + def _write(self, fp): + """ + Write method for Python 2. + + The one from configparser fails for non-ascii Windows accounts. + """ + if self._defaults: + fp.write('[{}]\n'.format(cp.DEFAULTSECT)) + for (key, value) in self._defaults.items(): + value_plus_end_of_line = str(value).replace('\n', '\n\t') + fp.write('{} = {}\n'.format(key, value_plus_end_of_line)) + + fp.write('\n') + + for section in self._sections: + fp.write('[{}]\n'.format(section)) + for (key, value) in self._sections[section].items(): + if key == '__name__': + continue + + if (value is not None) or (self._optcre == self.OPTCRE): + value = to_text_string(value) + value_plus_end_of_line = value.replace('\n', '\n\t') + key = ' = '.join((key, value_plus_end_of_line)) + + fp.write('{}\n'.format(key)) + + fp.write('\n') + + def _set(self, section, option, value, verbose): + """Set method.""" + if not self.has_section(section): + self.add_section(section) + + if not is_text_string(value): + value = repr(value) + + if verbose: + text = '[{}][{}] = {}'.format(section, option, value) + print(text) # spyder: test-skip + + super(DefaultsConfig, self).set(section, option, value) + + def _save(self): + """Save config into the associated .ini file.""" + fpath = self.get_config_fpath() + + def _write_file(fpath): + with io.open(fpath, 'w', encoding='utf-8') as configfile: + if PY2: + self._write(configfile) + else: + self.write(configfile) + + # See spyder-ide/spyder#1086 and spyder-ide/spyder#1242 for background + # on why this method contains all the exception handling. + try: + # The "easy" way + _write_file(fpath) + except EnvironmentError: + try: + # The "delete and sleep" way + if osp.isfile(fpath): + os.remove(fpath) + + time.sleep(0.05) + _write_file(fpath) + except Exception as e: + print('Failed to write user configuration file to disk, with ' + 'the exception shown below') # spyder: test-skip + print(e) # spyder: test-skip + + def get_config_fpath(self): + """Return the ini file where this configuration is stored.""" + path = self._path + config_file = osp.join(path, '{}.ini'.format(self._name)) + return config_file + + def set_defaults(self, defaults): + """Set default values and save to defaults folder location.""" + for section, options in defaults: + for option in options: + new_value = options[option] + self._set(section, option, new_value, verbose=False) + + +# ============================================================================ +# User config class +# ============================================================================ +class UserConfig(DefaultsConfig): + """ + UserConfig class, based on ConfigParser. + + Parameters + ---------- + name: str + Name of the config + path: str + Configuration file will be saved in path/%name%.ini + defaults: {} or [(str, {}),] + Dictionary containing options *or* list of tuples (sec_name, options) + load: bool + If a previous configuration file is found, load will take the values + from this existing file, instead of using default values. + version: str + version of the configuration file in 'major.minor.micro' format. + backup: bool + A backup will be created on version changes and on initial setup. + raw_mode: bool + If `True` do not apply any automatic conversion on values read from + the configuration. + remove_obsolete: bool + If `True`, values that were removed from the configuration on version + change, are removed from the saved configuration file. + + Notes + ----- + The 'get' and 'set' arguments number and type differ from the overriden + methods. 'defaults' is an attribute and not a method. + """ + DEFAULT_SECTION_NAME = 'main' + + def __init__(self, name, path, defaults=None, load=True, version=None, + backup=False, raw_mode=False, remove_obsolete=False, + external_plugin=False): + """UserConfig class, based on ConfigParser.""" + super(UserConfig, self).__init__(name=name, path=path) + + self._load = load + self._version = self._check_version(version) + self._backup = backup + self._raw = 1 if raw_mode else 0 + self._remove_obsolete = remove_obsolete + self._external_plugin = external_plugin + + self._module_source_path = get_module_source_path('spyder') + self._defaults_folder = 'defaults' + self._backup_folder = 'backups' + self._backup_suffix = '.bak' + self._defaults_name_prefix = 'defaults' + + # This attribute is overriding a method from cp.ConfigParser + self.defaults = self._check_defaults(defaults) + + if backup: + self._make_backup() + + if load: + # If config file already exists, it overrides Default options + previous_fpath = self.get_previous_config_fpath() + self._load_from_ini(previous_fpath) + old_version = self.get_version(version) + self._old_version = old_version + + # Save new defaults + self._save_new_defaults(self.defaults) + + # Updating defaults only if major/minor version is different + if (self._get_minor_version(version) + != self._get_minor_version(old_version)): + + if backup: + self._make_backup(version=old_version) + + self.apply_configuration_patches(old_version=old_version) + + # Remove deprecated options if major version has changed + if remove_obsolete: + self._remove_deprecated_options(old_version) + + # Set new version number + self.set_version(version, save=False) + + if defaults is None: + # If no defaults are defined set .ini file settings as default + self.set_as_defaults() + + # --- Helpers and checkers + # ------------------------------------------------------------------------ + @staticmethod + def _get_minor_version(version): + """Return the 'major.minor' components of the version.""" + return version[:version.rfind('.')] + + @staticmethod + def _get_major_version(version): + """Return the 'major' component of the version.""" + return version[:version.find('.')] + + @staticmethod + def _check_version(version): + """Check version is compliant with format.""" + regex_check = re.match(r'^(\d+).(\d+).(\d+)$', version) + if version is not None and regex_check is None: + raise ValueError('Version number {} is incorrect - must be in ' + 'major.minor.micro format'.format(version)) + + return version + + def _check_defaults(self, defaults): + """Check if defaults are valid and update defaults values.""" + if defaults is None: + defaults = [(self.DEFAULT_SECTION_NAME, {})] + elif isinstance(defaults, dict): + defaults = [(self.DEFAULT_SECTION_NAME, defaults)] + elif isinstance(defaults, list): + # Check is a list of tuples with strings and dictionaries + for sec, options in defaults: + assert is_text_string(sec) + assert isinstance(options, dict) + for opt, _ in options.items(): + assert is_text_string(opt) + else: + raise ValueError('`defaults` must be a dict or a list of tuples!') + + # This attribute is overriding a method from cp.ConfigParser + self.defaults = defaults + + if defaults is not None: + self.reset_to_defaults(save=False) + + return defaults + + @classmethod + def _check_section_option(cls, section, option): + """Check section and option types.""" + if section is None: + section = cls.DEFAULT_SECTION_NAME + elif not is_text_string(section): + raise RuntimeError("Argument 'section' must be a string") + + if not is_text_string(option): + raise RuntimeError("Argument 'option' must be a string") + + return section + + def _make_backup(self, version=None, old_version=None): + """ + Make a backup of the configuration file. + + If `old_version` is `None` a normal backup is made. If `old_version` + is provided, then the backup was requested for minor version changes + and appends the version number to the backup file. + """ + fpath = self.get_config_fpath() + fpath_backup = self.get_backup_fpath_from_version( + version=version, old_version=old_version) + path = os.path.dirname(fpath_backup) + + if not osp.isdir(path): + os.makedirs(path) + + try: + shutil.copyfile(fpath, fpath_backup) + except IOError: + pass + + def _load_from_ini(self, fpath): + """Load config from the associated .ini file found at `fpath`.""" + try: + if PY2: + # Python 2 + if osp.isfile(fpath): + try: + with io.open(fpath, encoding='utf-8') as configfile: + self.readfp(configfile) + except IOError: + error_text = "Failed reading file", fpath + print(error_text) # spyder: test-skip + else: + # Python 3 + self.read(fpath, encoding='utf-8') + except cp.MissingSectionHeaderError: + error_text = 'Warning: File contains no section headers.' + print(error_text) # spyder: test-skip + + def _load_old_defaults(self, old_version): + """Read old defaults.""" + old_defaults = cp.ConfigParser() + path, name = self.get_defaults_path_name_from_version(old_version) + old_defaults.read(osp.join(path, name + '.ini')) + return old_defaults + + def _save_new_defaults(self, defaults): + """Save new defaults.""" + path, name = self.get_defaults_path_name_from_version() + new_defaults = DefaultsConfig(name=name, path=path) + if not osp.isfile(new_defaults.get_config_fpath()): + new_defaults.set_defaults(defaults) + new_defaults._save() + + def _update_defaults(self, defaults, old_version, verbose=False): + """Update defaults after a change in version.""" + old_defaults = self._load_old_defaults(old_version) + for section, options in defaults: + for option in options: + new_value = options[option] + try: + old_val = old_defaults.get(section, option) + except (cp.NoSectionError, cp.NoOptionError): + old_val = None + + if old_val is None or to_text_string(new_value) != old_val: + self._set(section, option, new_value, verbose) + + def _remove_deprecated_options(self, old_version): + """ + Remove options which are present in the .ini file but not in defaults. + """ + old_defaults = self._load_old_defaults(old_version) + for section in old_defaults.sections(): + for option, _ in old_defaults.items(section, raw=self._raw): + if self.get_default(section, option) is NoDefault: + try: + self.remove_option(section, option) + if len(self.items(section, raw=self._raw)) == 0: + self.remove_section(section) + except cp.NoSectionError: + self.remove_section(section) + + # --- Compatibility API + # ------------------------------------------------------------------------ + def get_previous_config_fpath(self): + """Return the last configuration file used if found.""" + return self.get_config_fpath() + + def get_config_fpath_from_version(self, version=None): + """ + Return the configuration path for given version. + + If no version is provided, it returns the current file path. + """ + return self.get_config_fpath() + + def get_backup_fpath_from_version(self, version=None, old_version=None): + """ + Get backup location based on version. + + `old_version` can be used for checking compatibility whereas `version` + relates to adding the version to the file name. + + To be overridden if versions changed backup location. + """ + fpath = self.get_config_fpath() + path = osp.join(osp.dirname(fpath), self._backup_folder) + new_fpath = osp.join(path, osp.basename(fpath)) + if version is None: + backup_fpath = '{}{}'.format(new_fpath, self._backup_suffix) + else: + backup_fpath = "{}-{}{}".format(new_fpath, version, + self._backup_suffix) + return backup_fpath + + def get_defaults_path_name_from_version(self, old_version=None): + """ + Get defaults location based on version. + + To be overridden if versions changed defaults location. + """ + version = old_version if old_version else self._version + defaults_path = osp.join(osp.dirname(self.get_config_fpath()), + self._defaults_folder) + name = '{}-{}-{}'.format( + self._defaults_name_prefix, + self._name, + version, + ) + if not osp.isdir(defaults_path): + os.makedirs(defaults_path) + + return defaults_path, name + + def apply_configuration_patches(self, old_version=None): + """ + Apply any patch to configuration values on version changes. + + To be overridden if patches to configuration values are needed. + """ + pass + + # --- Public API + # ------------------------------------------------------------------------ + def get_version(self, version='0.0.0'): + """Return configuration (not application!) version.""" + return self.get(self.DEFAULT_SECTION_NAME, 'version', version) + + def set_version(self, version='0.0.0', save=True): + """Set configuration (not application!) version.""" + version = self._check_version(version) + self.set(self.DEFAULT_SECTION_NAME, 'version', version, save=save) + + def reset_to_defaults(self, save=True, verbose=False, section=None): + """Reset config to Default values.""" + for sec, options in self.defaults: + if section == None or section == sec: + for option in options: + value = options[option] + self._set(sec, option, value, verbose) + if save: + self._save() + + def set_as_defaults(self): + """Set defaults from the current config.""" + self.defaults = [] + for section in self.sections(): + secdict = {} + for option, value in self.items(section, raw=self._raw): + try: + value = ast.literal_eval(value) + except (SyntaxError, ValueError): + pass + secdict[option] = value + self.defaults.append((section, secdict)) + + def get_default(self, section, option): + """ + Get default value for a given `section` and `option`. + + This is useful for type checking in `get` method. + """ + section = self._check_section_option(section, option) + for sec, options in self.defaults: + if sec == section: + if option in options: + value = options[option] + break + else: + value = NoDefault + + return value + + def get(self, section, option, default=NoDefault): + """ + Get an option. + + Parameters + ---------- + section: str + Section name. If `None` is provide use the default section name. + option: str + Option name for `section`. + default: + Default value (if not specified, an exception will be raised if + option doesn't exist). + """ + section = self._check_section_option(section, option) + + if not self.has_section(section): + if default is NoDefault: + raise cp.NoSectionError(section) + else: + self.add_section(section) + + if not self.has_option(section, option): + if default is NoDefault: + raise cp.NoOptionError(option, section) + else: + self.set(section, option, default) + return default + + value = super(UserConfig, self).get(section, option, raw=self._raw) + + default_value = self.get_default(section, option) + if isinstance(default_value, bool): + value = ast.literal_eval(value) + elif isinstance(default_value, float): + value = float(value) + elif isinstance(default_value, int): + value = int(value) + elif is_text_string(default_value): + if PY2: + try: + value = value.decode('utf-8') + try: + # Some str config values expect to be eval after + # decoding + new_value = ast.literal_eval(value) + if is_text_string(new_value): + value = new_value + except (SyntaxError, ValueError): + pass + except (UnicodeEncodeError, UnicodeDecodeError): + pass + else: + try: + # Lists, tuples, ... + value = ast.literal_eval(value) + except (SyntaxError, ValueError): + pass + + return value + + def set_default(self, section, option, default_value): + """ + Set Default value for a given `section`, `option`. + + If no defaults exist, no default is created. To be able to set + defaults, a call to set_as_defaults is needed to create defaults + based on current values. + """ + section = self._check_section_option(section, option) + for sec, options in self.defaults: + if sec == section: + options[option] = default_value + + def set(self, section, option, value, verbose=False, save=True): + """ + Set an `option` on a given `section`. + + If section is None, the `option` is added to the default section. + """ + section = self._check_section_option(section, option) + default_value = self.get_default(section, option) + + if default_value is NoDefault: + # This let us save correctly string value options with + # no config default that contain non-ascii chars in + # Python 2 + if PY2 and is_text_string(value): + value = repr(value) + + default_value = value + self.set_default(section, option, default_value) + + if isinstance(default_value, bool): + value = bool(value) + elif isinstance(default_value, float): + value = float(value) + elif isinstance(default_value, int): + value = int(value) + elif not is_text_string(default_value): + value = repr(value) + + self._set(section, option, value, verbose) + if save: + self._save() + + def remove_section(self, section): + """Remove `section` and all options within it.""" + super(UserConfig, self).remove_section(section) + self._save() + + def remove_option(self, section, option): + """Remove `option` from `section`.""" + super(UserConfig, self).remove_option(section, option) + self._save() + + def cleanup(self): + """Remove .ini file associated to config.""" + os.remove(self.get_config_fpath()) + + def to_list(self): + """ + Return in list format. + + The format is [('section1', {'opt-1': value, ...}), + ('section2', {'opt-2': othervalue, ...}), ...] + """ + new_defaults = [] + self._load_from_ini(self.get_config_fpath()) + for section in self._sections: + sec_data = {} + for (option, _) in self.items(section): + sec_data[option] = self.get(section, option) + new_defaults.append((section, sec_data)) + + return new_defaults + + +class SpyderUserConfig(UserConfig): + + def get_previous_config_fpath(self): + """ + Override method. + + Return the last configuration file used if found. + """ + fpath = self.get_config_fpath() + + # We don't need to add the contents of the old spyder.ini to + # the configuration of external plugins. This was the cause + # of part two (the shortcut conflicts) of issue + # spyder-ide/spyder#11132 + if self._external_plugin: + previous_paths = [fpath] + else: + previous_paths = [ + # >= 51.0.0 + fpath, + # < 51.0.0 + os.path.join(get_conf_path(), 'spyder.ini'), + ] + + for fpath in previous_paths: + if osp.isfile(fpath): + break + + return fpath + + def get_config_fpath_from_version(self, version=None): + """ + Override method. + + Return the configuration path for given version. + + If no version is provided, it returns the current file path. + """ + if version is None or self._external_plugin: + fpath = self.get_config_fpath() + elif check_version(version, '51.0.0', '<'): + fpath = osp.join(get_conf_path(), 'spyder.ini') + else: + fpath = self.get_config_fpath() + + return fpath + + def get_backup_fpath_from_version(self, version=None, old_version=None): + """ + Override method. + + Make a backup of the configuration file. + """ + if old_version and check_version(old_version, '51.0.0', '<'): + name = 'spyder.ini' + fpath = os.path.join(get_conf_path(), name) + if version is None: + backup_fpath = "{}{}".format(fpath, self._backup_suffix) + else: + backup_fpath = "{}-{}{}".format(fpath, version, + self._backup_suffix) + else: + super_class = super(SpyderUserConfig, self) + backup_fpath = super_class.get_backup_fpath_from_version( + version, old_version) + + return backup_fpath + + def get_defaults_path_name_from_version(self, old_version=None): + """ + Override method. + + Get defaults location based on version. + """ + if old_version: + if check_version(old_version, '51.0.0', '<'): + name = '{}-{}'.format(self._defaults_name_prefix, old_version) + path = osp.join(get_conf_path(), 'defaults') + else: + super_class = super(SpyderUserConfig, self) + path, name = super_class.get_defaults_path_name_from_version( + old_version) + else: + super_class = super(SpyderUserConfig, self) + path, name = super_class.get_defaults_path_name_from_version() + + return path, name + + def apply_configuration_patches(self, old_version=None): + """ + Override method. + + Apply any patch to configuration values on version changes. + """ + self._update_defaults(self.defaults, old_version) + + if self._external_plugin: + return + if old_version and check_version(old_version, '44.1.0', '<'): + run_lines = to_text_string(self.get('ipython_console', + 'startup/run_lines')) + if run_lines is not NoDefault: + run_lines = run_lines.replace(',', '; ') + self.set('ipython_console', 'startup/run_lines', run_lines) + + +class MultiUserConfig(object): + """ + Multiuser config class which emulates the basic UserConfig interface. + + This class provides the same basic interface as UserConfig but allows + splitting the configuration sections and options among several files. + + The `name` is now a `name_map` where the sections and options per file name + are defined. + """ + DEFAULT_FILE_NAME = 'spyder' + + def __init__(self, name_map, path, defaults=None, load=True, version=None, + backup=False, raw_mode=False, remove_obsolete=False, + external_plugin=False): + """Multi user config class based on UserConfig class.""" + self._name_map = self._check_name_map(name_map) + self._path = path + self._defaults = defaults + self._load = load + self._version = version + self._backup = backup + self._raw_mode = 1 if raw_mode else 0 + self._remove_obsolete = remove_obsolete + self._external_plugin = external_plugin + + self._configs_map = {} + self._config_defaults_map = self._get_defaults_for_name_map(defaults, + name_map) + self._config_kwargs = { + 'path': path, + 'defaults': defaults, + 'load': load, + 'version': version, + 'backup': backup, + 'raw_mode': raw_mode, + 'remove_obsolete': False, # This will be handled later on if True + 'external_plugin': external_plugin + } + + for name in name_map: + defaults = self._config_defaults_map.get(name) + mod_kwargs = { + 'name': name, + 'defaults': defaults, + } + new_kwargs = self._config_kwargs.copy() + new_kwargs.update(mod_kwargs) + config_class = self.get_config_class() + self._configs_map[name] = config_class(**new_kwargs) + + # Remove deprecated options if major version has changed + default_config = self._configs_map.get(self.DEFAULT_FILE_NAME) + major_ver = default_config._get_major_version(version) + major_old_ver = default_config._get_major_version( + default_config._old_version) + + # Now we can apply remove_obsolete + if remove_obsolete or major_ver != major_old_ver: + for _, config in self._configs_map.items(): + config._remove_deprecated_options(config._old_version) + + def _get_config(self, section, option): + """Get the correct configuration based on section and option.""" + # Check the filemap first + name = self._get_name_from_map(section, option) + config_value = self._configs_map.get(name, None) + + if config_value is None: + config_value = self._configs_map[self.DEFAULT_FILE_NAME] + return config_value + + def _check_name_map(self, name_map): + """Check `name_map` follows the correct format.""" + # Check section option paris are not repeated + sections_options = [] + for _, sec_opts in name_map.items(): + for section, options in sec_opts: + for option in options: + sec_opt = (section, option) + if sec_opt not in sections_options: + sections_options.append(sec_opt) + else: + error_msg = ( + 'Different files are holding the same ' + 'section/option: "{}/{}"!'.format(section, option) + ) + raise ValueError(error_msg) + return name_map + + @staticmethod + def _get_section_from_defaults(defaults, section): + """Get the section contents from the defaults.""" + for sec, options in defaults: + if section == sec: + value = options + break + else: + raise ValueError('section "{}" not found!'.format(section)) + + return value + + @staticmethod + def _get_option_from_defaults(defaults, section, option): + """Get the section,option value from the defaults.""" + value = NoDefault + for sec, options in defaults: + if section == sec: + value = options.get(option, NoDefault) + break + return value + + @staticmethod + def _remove_section_from_defaults(defaults, section): + """Remove section from defaults.""" + idx_remove = None + for idx, (sec, _) in enumerate(defaults): + if section == sec: + idx_remove = idx + break + + if idx_remove is not None: + defaults.pop(idx) + + @staticmethod + def _remove_option_from_defaults(defaults, section, option): + """Remove section,option from defaults.""" + for sec, options in defaults: + if section == sec: + if option in options: + options.pop(option) + break + + def _get_name_from_map(self, section=None, option=None): + """ + Search for section and option on the name_map and return the name. + """ + for name, sec_opts in self._name_map.items(): + # Ignore the main section + default_sec_name = self._configs_map.get(name).DEFAULT_SECTION_NAME + if name == default_sec_name: + continue + for sec, options in sec_opts: + if sec == section: + if len(options) == 0: + return name + else: + for opt in options: + if opt == option: + return name + + @classmethod + def _get_defaults_for_name_map(cls, defaults, name_map): + """Split the global defaults using the name_map.""" + name_map_config = {} + defaults_copy = copy.deepcopy(defaults) + + for name, sec_opts in name_map.items(): + default_map_for_name = [] + if len(sec_opts) == 0: + name_map_config[name] = defaults_copy + else: + for section, options in sec_opts: + if len(options) == 0: + # Use all on section + sec = cls._get_section_from_defaults(defaults_copy, + section) + + # Remove section from defaults + cls._remove_section_from_defaults(defaults_copy, + section) + + else: + # Iterate and pop! + sec = {} + for opt in options: + val = cls._get_option_from_defaults(defaults_copy, + section, opt) + if val is not NoDefault: + sec[opt] = val + + # Remove option from defaults + cls._remove_option_from_defaults(defaults_copy, + section, opt) + + # Add to config map + default_map_for_name.append((section, sec)) + + name_map_config[name] = default_map_for_name + + return name_map_config + + def get_config_class(self): + """Return the UserConfig class to use.""" + return SpyderUserConfig + + def sections(self): + """Return all sections of the configuration file.""" + sections = set() + for _, config in self._configs_map.items(): + for section in config.sections(): + sections.add(section) + + return list(sorted(sections)) + + def items(self, section): + """Return all the items option/values for the given section.""" + config = self._get_config(section, None) + if config is None: + config = self._configs_map[self.DEFAULT_FILE_NAME] + + if config.has_section(section): + return config.items(section=section) + else: + return None + + def options(self, section): + """Return all the options for the given section.""" + config = self._get_config(section, None) + return config.options(section=section) + + def get_default(self, section, option): + """ + Get Default value for a given `section` and `option`. + + This is useful for type checking in `get` method. + """ + config = self._get_config(section, option) + if config is None: + config = self._configs_map[self.DEFAULT_FILE_NAME] + return config.get_default(section, option) + + def get(self, section, option, default=NoDefault): + """ + Get an `option` on a given `section`. + + If section is None, the `option` is requested from default section. + """ + config = self._get_config(section, option) + + if config is None: + config = self._configs_map[self.DEFAULT_FILE_NAME] + + return config.get(section=section, option=option, default=default) + + def set(self, section, option, value, verbose=False, save=True): + """ + Set an `option` on a given `section`. + + If section is None, the `option` is added to the default section. + """ + config = self._get_config(section, option) + config.set(section=section, option=option, value=value, + verbose=verbose, save=save) + + def reset_to_defaults(self, section=None): + """Reset configuration to Default values.""" + for _, config in self._configs_map.items(): + config.reset_to_defaults(section=section) + + def remove_section(self, section): + """Remove `section` and all options within it.""" + config = self._get_config(section, None) + config.remove_section(section) + + def remove_option(self, section, option): + """Remove `option` from `section`.""" + config = self._get_config(section, option) + config.remove_option(section, option) + + def cleanup(self): + """Remove .ini files associated to configurations.""" + for _, config in self._configs_map.items(): + os.remove(config.get_config_fpath()) + + +class PluginConfig(UserConfig): + """Plugin configuration handler.""" + + +class PluginMultiConfig(MultiUserConfig): + """Plugin configuration handler with multifile support.""" + + def get_config_class(self): + return PluginConfig diff --git a/spyder/dependencies.py b/spyder/dependencies.py index b3a6fdf5413..d382ce818cb 100644 --- a/spyder/dependencies.py +++ b/spyder/dependencies.py @@ -1,448 +1,448 @@ -# -*- coding: utf-8 -*- -# -# Copyright © Spyder Project Contributors -# Licensed under the terms of the MIT License -# (see spyder/__init__.py for details) - -"""Module checking Spyder runtime dependencies""" - -# Standard library imports -import os -import os.path as osp -import sys - -# Local imports -from spyder.config.base import _, is_pynsist, running_in_ci, running_in_mac_app -from spyder.utils import programs - -HERE = osp.dirname(osp.abspath(__file__)) - -# ============================================================================= -# Kind of dependency -# ============================================================================= -MANDATORY = 'mandatory' -OPTIONAL = 'optional' -PLUGIN = 'spyder plugins' - - -# ============================================================================= -# Versions -# ============================================================================= -# Hard dependencies -APPLAUNCHSERVICES_REQVER = '>=0.3.0' -ATOMICWRITES_REQVER = '>=1.2.0' -CHARDET_REQVER = '>=2.0.0' -CLOUDPICKLE_REQVER = '>=0.5.0' -COOKIECUTTER_REQVER = '>=1.6.0' -DIFF_MATCH_PATCH_REQVER = '>=20181111' -# None for pynsist install for now -# (check way to add dist.info/egg.info from packages without wheels available) -INTERVALTREE_REQVER = None if is_pynsist() else '>=3.0.2' -IPYTHON_REQVER = ">=7.31.1;<8.0.0" -JEDI_REQVER = '>=0.17.2;<0.19.0' -JELLYFISH_REQVER = '>=0.7' -JSONSCHEMA_REQVER = '>=3.2.0' -KEYRING_REQVER = '>=17.0.0' -NBCONVERT_REQVER = '>=4.0' -NUMPYDOC_REQVER = '>=0.6.0' -PARAMIKO_REQVER = '>=2.4.0' -PARSO_REQVER = '>=0.7.0;<0.9.0' -PEXPECT_REQVER = '>=4.4.0' -PICKLESHARE_REQVER = '>=0.4' -PSUTIL_REQVER = '>=5.3' -PYGMENTS_REQVER = '>=2.0' -PYLINT_REQVER = '>=2.5.0;<3.0' -PYLSP_REQVER = '>=1.5.0;<1.6.0' -PYLSP_BLACK_REQVER = '>=1.2.0' -PYLS_SPYDER_REQVER = '>=0.4.0' -PYXDG_REQVER = '>=0.26' -PYZMQ_REQVER = '>=22.1.0' -QDARKSTYLE_REQVER = '>=3.0.2;<3.1.0' -QSTYLIZER_REQVER = '>=0.1.10' -QTAWESOME_REQVER = '>=1.0.2' -QTCONSOLE_REQVER = '>=5.3.0;<5.4.0' -QTPY_REQVER = '>=2.1.0' -RTREE_REQVER = '>=0.9.7' -SETUPTOOLS_REQVER = '>=49.6.0' -SPHINX_REQVER = '>=0.6.6' -SPYDER_KERNELS_REQVER = '>=2.3.2;<2.4.0' -TEXTDISTANCE_REQVER = '>=4.2.0' -THREE_MERGE_REQVER = '>=0.1.1' -# None for pynsist install for now -# (check way to add dist.info/egg.info from packages without wheels available) -WATCHDOG_REQVER = None if is_pynsist() else '>=0.10.3' - - -# Optional dependencies -CYTHON_REQVER = '>=0.21' -MATPLOTLIB_REQVER = '>=3.0.0' -NUMPY_REQVER = '>=1.7' -PANDAS_REQVER = '>=1.1.1' -SCIPY_REQVER = '>=0.17.0' -SYMPY_REQVER = '>=0.7.3' - - -# ============================================================================= -# Descriptions -# NOTE: We declare our dependencies in **alphabetical** order -# If some dependencies are limited to some systems only, add a 'display' key. -# See 'applaunchservices' for an example. -# ============================================================================= -# List of descriptions -DESCRIPTIONS = [ - {'modname': "applaunchservices", - 'package_name': "applaunchservices", - 'features': _("Notify macOS that Spyder can open Python files"), - 'required_version': APPLAUNCHSERVICES_REQVER, - 'display': sys.platform == "darwin" and not running_in_mac_app()}, - {'modname': "atomicwrites", - 'package_name': "atomicwrites", - 'features': _("Atomic file writes in the Editor"), - 'required_version': ATOMICWRITES_REQVER}, - {'modname': "chardet", - 'package_name': "chardet", - 'features': _("Character encoding auto-detection for the Editor"), - 'required_version': CHARDET_REQVER}, - {'modname': "cloudpickle", - 'package_name': "cloudpickle", - 'features': _("Handle communications between kernel and frontend"), - 'required_version': CLOUDPICKLE_REQVER}, - {'modname': "cookiecutter", - 'package_name': "cookiecutter", - 'features': _("Create projects from cookiecutter templates"), - 'required_version': COOKIECUTTER_REQVER}, - {'modname': "diff_match_patch", - 'package_name': "diff-match-patch", - 'features': _("Compute text file diff changes during edition"), - 'required_version': DIFF_MATCH_PATCH_REQVER}, - {'modname': "intervaltree", - 'package_name': "intervaltree", - 'features': _("Compute folding range nesting levels"), - 'required_version': INTERVALTREE_REQVER}, - {'modname': "IPython", - 'package_name': "IPython", - 'features': _("IPython interactive python environment"), - 'required_version': IPYTHON_REQVER}, - {'modname': "jedi", - 'package_name': "jedi", - 'features': _("Main backend for the Python Language Server"), - 'required_version': JEDI_REQVER}, - {'modname': "jellyfish", - 'package_name': "jellyfish", - 'features': _("Optimize algorithms for folding"), - 'required_version': JELLYFISH_REQVER}, - {'modname': 'jsonschema', - 'package_name': 'jsonschema', - 'features': _('Verify if snippets files are valid'), - 'required_version': JSONSCHEMA_REQVER}, - {'modname': "keyring", - 'package_name': "keyring", - 'features': _("Save Github credentials to report internal " - "errors securely"), - 'required_version': KEYRING_REQVER}, - {'modname': "nbconvert", - 'package_name': "nbconvert", - 'features': _("Manipulate Jupyter notebooks in the Editor"), - 'required_version': NBCONVERT_REQVER}, - {'modname': "numpydoc", - 'package_name': "numpydoc", - 'features': _("Improve code completion for objects that use Numpy docstrings"), - 'required_version': NUMPYDOC_REQVER}, - {'modname': "paramiko", - 'package_name': "paramiko", - 'features': _("Connect to remote kernels through SSH"), - 'required_version': PARAMIKO_REQVER, - 'display': os.name == 'nt'}, - {'modname': "parso", - 'package_name': "parso", - 'features': _("Python parser that supports error recovery and " - "round-trip parsing"), - 'required_version': PARSO_REQVER}, - {'modname': "pexpect", - 'package_name': "pexpect", - 'features': _("Stdio support for our language server client"), - 'required_version': PEXPECT_REQVER}, - {'modname': "pickleshare", - 'package_name': "pickleshare", - 'features': _("Cache the list of installed Python modules"), - 'required_version': PICKLESHARE_REQVER}, - {'modname': "psutil", - 'package_name': "psutil", - 'features': _("CPU and memory usage info in the status bar"), - 'required_version': PSUTIL_REQVER}, - {'modname': "pygments", - 'package_name': "pygments", - 'features': _("Syntax highlighting for a lot of file types in the Editor"), - 'required_version': PYGMENTS_REQVER}, - {'modname': "pylint", - 'package_name': "pylint", - 'features': _("Static code analysis"), - 'required_version': PYLINT_REQVER}, - {'modname': 'pylsp', - 'package_name': 'python-lsp-server', - 'features': _("Code completion and linting for the Editor"), - 'required_version': PYLSP_REQVER}, - {'modname': 'pylsp_black', - 'package_name': 'python-lsp-black', - 'features': _("Autoformat Python files in the Editor with the Black " - "package"), - 'required_version': PYLSP_BLACK_REQVER}, - {'modname': 'pyls_spyder', - 'package_name': 'pyls-spyder', - 'features': _('Spyder plugin for the Python LSP Server'), - 'required_version': PYLS_SPYDER_REQVER}, - {'modname': "xdg", - 'package_name': "pyxdg", - 'features': _("Parse desktop files on Linux"), - 'required_version': PYXDG_REQVER, - 'display': sys.platform.startswith('linux')}, - {'modname': "zmq", - 'package_name': "pyzmq", - 'features': _("Client for the language server protocol (LSP)"), - 'required_version': PYZMQ_REQVER}, - {'modname': "qdarkstyle", - 'package_name': "qdarkstyle", - 'features': _("Dark style for the entire interface"), - 'required_version': QDARKSTYLE_REQVER}, - {'modname': "qstylizer", - 'package_name': "qstylizer", - 'features': _("Customize Qt stylesheets"), - 'required_version': QSTYLIZER_REQVER}, - {'modname': "qtawesome", - 'package_name': "qtawesome", - 'features': _("Icon theme based on FontAwesome and Material Design icons"), - 'required_version': QTAWESOME_REQVER}, - {'modname': "qtconsole", - 'package_name': "qtconsole", - 'features': _("Main package for the IPython console"), - 'required_version': QTCONSOLE_REQVER}, - {'modname': "qtpy", - 'package_name': "qtpy", - 'features': _("Abstraction layer for Python Qt bindings."), - 'required_version': QTPY_REQVER}, - {'modname': "rtree", - 'package_name': "rtree", - 'features': _("Fast access to code snippets regions"), - 'required_version': RTREE_REQVER}, - {'modname': "setuptools", - 'package_name': "setuptools", - 'features': _("Determine package version"), - 'required_version': SETUPTOOLS_REQVER}, - {'modname': "sphinx", - 'package_name': "sphinx", - 'features': _("Show help for objects in the Editor and Consoles in a dedicated pane"), - 'required_version': SPHINX_REQVER}, - {'modname': "spyder_kernels", - 'package_name': "spyder-kernels", - 'features': _("Jupyter kernels for the Spyder console"), - 'required_version': SPYDER_KERNELS_REQVER}, - {'modname': 'textdistance', - 'package_name': "textdistance", - 'features': _('Compute distances between strings'), - 'required_version': TEXTDISTANCE_REQVER}, - {'modname': "three_merge", - 'package_name': "three-merge", - 'features': _("3-way merge algorithm to merge document changes"), - 'required_version': THREE_MERGE_REQVER}, - {'modname': "watchdog", - 'package_name': "watchdog", - 'features': _("Watch file changes on project directories"), - 'required_version': WATCHDOG_REQVER}, -] - - -# Optional dependencies -DESCRIPTIONS += [ - {'modname': "cython", - 'package_name': "cython", - 'features': _("Run Cython files in the IPython Console"), - 'required_version': CYTHON_REQVER, - 'kind': OPTIONAL}, - {'modname': "matplotlib", - 'package_name': "matplotlib", - 'features': _("2D/3D plotting in the IPython console"), - 'required_version': MATPLOTLIB_REQVER, - 'kind': OPTIONAL}, - {'modname': "numpy", - 'package_name': "numpy", - 'features': _("View and edit two and three dimensional arrays in the Variable Explorer"), - 'required_version': NUMPY_REQVER, - 'kind': OPTIONAL}, - {'modname': 'pandas', - 'package_name': 'pandas', - 'features': _("View and edit DataFrames and Series in the Variable Explorer"), - 'required_version': PANDAS_REQVER, - 'kind': OPTIONAL}, - {'modname': "scipy", - 'package_name': "scipy", - 'features': _("Import Matlab workspace files in the Variable Explorer"), - 'required_version': SCIPY_REQVER, - 'kind': OPTIONAL}, - {'modname': "sympy", - 'package_name': "sympy", - 'features': _("Symbolic mathematics in the IPython Console"), - 'required_version': SYMPY_REQVER, - 'kind': OPTIONAL} -] - - -# ============================================================================= -# Code -# ============================================================================= -class Dependency(object): - """Spyder's dependency - - version may starts with =, >=, > or < to specify the exact requirement ; - multiple conditions may be separated by ';' (e.g. '>=0.13;<1.0')""" - - OK = 'OK' - NOK = 'NOK' - - def __init__(self, modname, package_name, features, required_version, - installed_version=None, kind=MANDATORY): - self.modname = modname - self.package_name = package_name - self.features = features - self.required_version = required_version - self.kind = kind - - # Although this is not necessarily the case, it's customary that a - # package's distribution name be it's name on PyPI with hyphens - # replaced by underscores. - # Example: - # * Package name: python-lsp-black. - # * Distribution name: python_lsp_black - self.distribution_name = self.package_name.replace('-', '_') - - if installed_version is None: - try: - self.installed_version = programs.get_module_version(modname) - if not self.installed_version: - # Use get_package_version and the distribution name - # because there are cases for which the version can't - # be obtained from the module (e.g. pylsp_black). - self.installed_version = programs.get_package_version( - self.distribution_name) - except Exception: - # NOTE: Don't add any exception type here! - # Modules can fail to import in several ways besides - # ImportError - self.installed_version = None - else: - self.installed_version = installed_version - - def check(self): - """Check if dependency is installed""" - if self.required_version: - installed = programs.is_module_installed( - self.modname, - self.required_version, - distribution_name=self.distribution_name - ) - return installed - else: - return True - - def get_installed_version(self): - """Return dependency status (string)""" - if self.check(): - return '%s (%s)' % (self.installed_version, self.OK) - else: - return '%s (%s)' % (self.installed_version, self.NOK) - - def get_status(self): - """Return dependency status (string)""" - if self.check(): - return self.OK - else: - return self.NOK - - -DEPENDENCIES = [] - - -def add(modname, package_name, features, required_version, - installed_version=None, kind=MANDATORY): - """Add Spyder dependency""" - global DEPENDENCIES - for dependency in DEPENDENCIES: - # Avoid showing an unnecessary error when running our tests. - if running_in_ci() and 'spyder_boilerplate' in modname: - continue - - if dependency.modname == modname: - raise ValueError( - f"Dependency has already been registered: {modname}") - - DEPENDENCIES += [Dependency(modname, package_name, features, - required_version, - installed_version, kind)] - - -def check(modname): - """Check if required dependency is installed""" - for dependency in DEPENDENCIES: - if dependency.modname == modname: - return dependency.check() - else: - raise RuntimeError("Unknown dependency %s" % modname) - - -def status(deps=DEPENDENCIES, linesep=os.linesep): - """Return a status of dependencies.""" - maxwidth = 0 - data = [] - - # Find maximum width - for dep in deps: - title = dep.modname - if dep.required_version is not None: - title += ' ' + dep.required_version - - maxwidth = max([maxwidth, len(title)]) - dep_order = {MANDATORY: '0', OPTIONAL: '1', PLUGIN: '2'} - order_dep = {'0': MANDATORY, '1': OPTIONAL, '2': PLUGIN} - data.append([dep_order[dep.kind], title, dep.get_installed_version()]) - - # Construct text and sort by kind and name - maxwidth += 1 - text = "" - prev_order = '-1' - for order, title, version in sorted( - data, key=lambda x: x[0] + x[1].lower()): - if order != prev_order: - name = order_dep[order] - if name == MANDATORY: - text += f'# {name.capitalize()}:{linesep}' - else: - text += f'{linesep}# {name.capitalize()}:{linesep}' - prev_order = order - - text += f'{title.ljust(maxwidth)}: {version}{linesep}' - - # Remove spurious linesep when reporting deps to Github - if not linesep == '
': - text = text[:-1] - - return text - - -def missing_dependencies(): - """Return the status of missing dependencies (if any)""" - missing_deps = [] - for dependency in DEPENDENCIES: - if dependency.kind != OPTIONAL and not dependency.check(): - missing_deps.append(dependency) - - if missing_deps: - return status(deps=missing_deps, linesep='
') - else: - return "" - - -def declare_dependencies(): - for dep in DESCRIPTIONS: - if dep.get('display', True): - add(dep['modname'], dep['package_name'], - dep['features'], dep['required_version'], - kind=dep.get('kind', MANDATORY)) +# -*- coding: utf-8 -*- +# +# Copyright © Spyder Project Contributors +# Licensed under the terms of the MIT License +# (see spyder/__init__.py for details) + +"""Module checking Spyder runtime dependencies""" + +# Standard library imports +import os +import os.path as osp +import sys + +# Local imports +from spyder.config.base import _, is_pynsist, running_in_ci, running_in_mac_app +from spyder.utils import programs + +HERE = osp.dirname(osp.abspath(__file__)) + +# ============================================================================= +# Kind of dependency +# ============================================================================= +MANDATORY = 'mandatory' +OPTIONAL = 'optional' +PLUGIN = 'spyder plugins' + + +# ============================================================================= +# Versions +# ============================================================================= +# Hard dependencies +APPLAUNCHSERVICES_REQVER = '>=0.3.0' +ATOMICWRITES_REQVER = '>=1.2.0' +CHARDET_REQVER = '>=2.0.0' +CLOUDPICKLE_REQVER = '>=0.5.0' +COOKIECUTTER_REQVER = '>=1.6.0' +DIFF_MATCH_PATCH_REQVER = '>=20181111' +# None for pynsist install for now +# (check way to add dist.info/egg.info from packages without wheels available) +INTERVALTREE_REQVER = None if is_pynsist() else '>=3.0.2' +IPYTHON_REQVER = ">=7.31.1;<8.0.0" +JEDI_REQVER = '>=0.17.2;<0.19.0' +JELLYFISH_REQVER = '>=0.7' +JSONSCHEMA_REQVER = '>=3.2.0' +KEYRING_REQVER = '>=17.0.0' +NBCONVERT_REQVER = '>=4.0' +NUMPYDOC_REQVER = '>=0.6.0' +PARAMIKO_REQVER = '>=2.4.0' +PARSO_REQVER = '>=0.7.0;<0.9.0' +PEXPECT_REQVER = '>=4.4.0' +PICKLESHARE_REQVER = '>=0.4' +PSUTIL_REQVER = '>=5.3' +PYGMENTS_REQVER = '>=2.0' +PYLINT_REQVER = '>=2.5.0;<3.0' +PYLSP_REQVER = '>=1.5.0;<1.6.0' +PYLSP_BLACK_REQVER = '>=1.2.0' +PYLS_SPYDER_REQVER = '>=0.4.0' +PYXDG_REQVER = '>=0.26' +PYZMQ_REQVER = '>=22.1.0' +QDARKSTYLE_REQVER = '>=3.0.2;<3.1.0' +QSTYLIZER_REQVER = '>=0.1.10' +QTAWESOME_REQVER = '>=1.0.2' +QTCONSOLE_REQVER = '>=5.3.0;<5.4.0' +QTPY_REQVER = '>=2.1.0' +RTREE_REQVER = '>=0.9.7' +SETUPTOOLS_REQVER = '>=49.6.0' +SPHINX_REQVER = '>=0.6.6' +SPYDER_KERNELS_REQVER = '>=2.3.2;<2.4.0' +TEXTDISTANCE_REQVER = '>=4.2.0' +THREE_MERGE_REQVER = '>=0.1.1' +# None for pynsist install for now +# (check way to add dist.info/egg.info from packages without wheels available) +WATCHDOG_REQVER = None if is_pynsist() else '>=0.10.3' + + +# Optional dependencies +CYTHON_REQVER = '>=0.21' +MATPLOTLIB_REQVER = '>=3.0.0' +NUMPY_REQVER = '>=1.7' +PANDAS_REQVER = '>=1.1.1' +SCIPY_REQVER = '>=0.17.0' +SYMPY_REQVER = '>=0.7.3' + + +# ============================================================================= +# Descriptions +# NOTE: We declare our dependencies in **alphabetical** order +# If some dependencies are limited to some systems only, add a 'display' key. +# See 'applaunchservices' for an example. +# ============================================================================= +# List of descriptions +DESCRIPTIONS = [ + {'modname': "applaunchservices", + 'package_name': "applaunchservices", + 'features': _("Notify macOS that Spyder can open Python files"), + 'required_version': APPLAUNCHSERVICES_REQVER, + 'display': sys.platform == "darwin" and not running_in_mac_app()}, + {'modname': "atomicwrites", + 'package_name': "atomicwrites", + 'features': _("Atomic file writes in the Editor"), + 'required_version': ATOMICWRITES_REQVER}, + {'modname': "chardet", + 'package_name': "chardet", + 'features': _("Character encoding auto-detection for the Editor"), + 'required_version': CHARDET_REQVER}, + {'modname': "cloudpickle", + 'package_name': "cloudpickle", + 'features': _("Handle communications between kernel and frontend"), + 'required_version': CLOUDPICKLE_REQVER}, + {'modname': "cookiecutter", + 'package_name': "cookiecutter", + 'features': _("Create projects from cookiecutter templates"), + 'required_version': COOKIECUTTER_REQVER}, + {'modname': "diff_match_patch", + 'package_name': "diff-match-patch", + 'features': _("Compute text file diff changes during edition"), + 'required_version': DIFF_MATCH_PATCH_REQVER}, + {'modname': "intervaltree", + 'package_name': "intervaltree", + 'features': _("Compute folding range nesting levels"), + 'required_version': INTERVALTREE_REQVER}, + {'modname': "IPython", + 'package_name': "IPython", + 'features': _("IPython interactive python environment"), + 'required_version': IPYTHON_REQVER}, + {'modname': "jedi", + 'package_name': "jedi", + 'features': _("Main backend for the Python Language Server"), + 'required_version': JEDI_REQVER}, + {'modname': "jellyfish", + 'package_name': "jellyfish", + 'features': _("Optimize algorithms for folding"), + 'required_version': JELLYFISH_REQVER}, + {'modname': 'jsonschema', + 'package_name': 'jsonschema', + 'features': _('Verify if snippets files are valid'), + 'required_version': JSONSCHEMA_REQVER}, + {'modname': "keyring", + 'package_name': "keyring", + 'features': _("Save Github credentials to report internal " + "errors securely"), + 'required_version': KEYRING_REQVER}, + {'modname': "nbconvert", + 'package_name': "nbconvert", + 'features': _("Manipulate Jupyter notebooks in the Editor"), + 'required_version': NBCONVERT_REQVER}, + {'modname': "numpydoc", + 'package_name': "numpydoc", + 'features': _("Improve code completion for objects that use Numpy docstrings"), + 'required_version': NUMPYDOC_REQVER}, + {'modname': "paramiko", + 'package_name': "paramiko", + 'features': _("Connect to remote kernels through SSH"), + 'required_version': PARAMIKO_REQVER, + 'display': os.name == 'nt'}, + {'modname': "parso", + 'package_name': "parso", + 'features': _("Python parser that supports error recovery and " + "round-trip parsing"), + 'required_version': PARSO_REQVER}, + {'modname': "pexpect", + 'package_name': "pexpect", + 'features': _("Stdio support for our language server client"), + 'required_version': PEXPECT_REQVER}, + {'modname': "pickleshare", + 'package_name': "pickleshare", + 'features': _("Cache the list of installed Python modules"), + 'required_version': PICKLESHARE_REQVER}, + {'modname': "psutil", + 'package_name': "psutil", + 'features': _("CPU and memory usage info in the status bar"), + 'required_version': PSUTIL_REQVER}, + {'modname': "pygments", + 'package_name': "pygments", + 'features': _("Syntax highlighting for a lot of file types in the Editor"), + 'required_version': PYGMENTS_REQVER}, + {'modname': "pylint", + 'package_name': "pylint", + 'features': _("Static code analysis"), + 'required_version': PYLINT_REQVER}, + {'modname': 'pylsp', + 'package_name': 'python-lsp-server', + 'features': _("Code completion and linting for the Editor"), + 'required_version': PYLSP_REQVER}, + {'modname': 'pylsp_black', + 'package_name': 'python-lsp-black', + 'features': _("Autoformat Python files in the Editor with the Black " + "package"), + 'required_version': PYLSP_BLACK_REQVER}, + {'modname': 'pyls_spyder', + 'package_name': 'pyls-spyder', + 'features': _('Spyder plugin for the Python LSP Server'), + 'required_version': PYLS_SPYDER_REQVER}, + {'modname': "xdg", + 'package_name': "pyxdg", + 'features': _("Parse desktop files on Linux"), + 'required_version': PYXDG_REQVER, + 'display': sys.platform.startswith('linux')}, + {'modname': "zmq", + 'package_name': "pyzmq", + 'features': _("Client for the language server protocol (LSP)"), + 'required_version': PYZMQ_REQVER}, + {'modname': "qdarkstyle", + 'package_name': "qdarkstyle", + 'features': _("Dark style for the entire interface"), + 'required_version': QDARKSTYLE_REQVER}, + {'modname': "qstylizer", + 'package_name': "qstylizer", + 'features': _("Customize Qt stylesheets"), + 'required_version': QSTYLIZER_REQVER}, + {'modname': "qtawesome", + 'package_name': "qtawesome", + 'features': _("Icon theme based on FontAwesome and Material Design icons"), + 'required_version': QTAWESOME_REQVER}, + {'modname': "qtconsole", + 'package_name': "qtconsole", + 'features': _("Main package for the IPython console"), + 'required_version': QTCONSOLE_REQVER}, + {'modname': "qtpy", + 'package_name': "qtpy", + 'features': _("Abstraction layer for Python Qt bindings."), + 'required_version': QTPY_REQVER}, + {'modname': "rtree", + 'package_name': "rtree", + 'features': _("Fast access to code snippets regions"), + 'required_version': RTREE_REQVER}, + {'modname': "setuptools", + 'package_name': "setuptools", + 'features': _("Determine package version"), + 'required_version': SETUPTOOLS_REQVER}, + {'modname': "sphinx", + 'package_name': "sphinx", + 'features': _("Show help for objects in the Editor and Consoles in a dedicated pane"), + 'required_version': SPHINX_REQVER}, + {'modname': "spyder_kernels", + 'package_name': "spyder-kernels", + 'features': _("Jupyter kernels for the Spyder console"), + 'required_version': SPYDER_KERNELS_REQVER}, + {'modname': 'textdistance', + 'package_name': "textdistance", + 'features': _('Compute distances between strings'), + 'required_version': TEXTDISTANCE_REQVER}, + {'modname': "three_merge", + 'package_name': "three-merge", + 'features': _("3-way merge algorithm to merge document changes"), + 'required_version': THREE_MERGE_REQVER}, + {'modname': "watchdog", + 'package_name': "watchdog", + 'features': _("Watch file changes on project directories"), + 'required_version': WATCHDOG_REQVER}, +] + + +# Optional dependencies +DESCRIPTIONS += [ + {'modname': "cython", + 'package_name': "cython", + 'features': _("Run Cython files in the IPython Console"), + 'required_version': CYTHON_REQVER, + 'kind': OPTIONAL}, + {'modname': "matplotlib", + 'package_name': "matplotlib", + 'features': _("2D/3D plotting in the IPython console"), + 'required_version': MATPLOTLIB_REQVER, + 'kind': OPTIONAL}, + {'modname': "numpy", + 'package_name': "numpy", + 'features': _("View and edit two and three dimensional arrays in the Variable Explorer"), + 'required_version': NUMPY_REQVER, + 'kind': OPTIONAL}, + {'modname': 'pandas', + 'package_name': 'pandas', + 'features': _("View and edit DataFrames and Series in the Variable Explorer"), + 'required_version': PANDAS_REQVER, + 'kind': OPTIONAL}, + {'modname': "scipy", + 'package_name': "scipy", + 'features': _("Import Matlab workspace files in the Variable Explorer"), + 'required_version': SCIPY_REQVER, + 'kind': OPTIONAL}, + {'modname': "sympy", + 'package_name': "sympy", + 'features': _("Symbolic mathematics in the IPython Console"), + 'required_version': SYMPY_REQVER, + 'kind': OPTIONAL} +] + + +# ============================================================================= +# Code +# ============================================================================= +class Dependency(object): + """Spyder's dependency + + version may starts with =, >=, > or < to specify the exact requirement ; + multiple conditions may be separated by ';' (e.g. '>=0.13;<1.0')""" + + OK = 'OK' + NOK = 'NOK' + + def __init__(self, modname, package_name, features, required_version, + installed_version=None, kind=MANDATORY): + self.modname = modname + self.package_name = package_name + self.features = features + self.required_version = required_version + self.kind = kind + + # Although this is not necessarily the case, it's customary that a + # package's distribution name be it's name on PyPI with hyphens + # replaced by underscores. + # Example: + # * Package name: python-lsp-black. + # * Distribution name: python_lsp_black + self.distribution_name = self.package_name.replace('-', '_') + + if installed_version is None: + try: + self.installed_version = programs.get_module_version(modname) + if not self.installed_version: + # Use get_package_version and the distribution name + # because there are cases for which the version can't + # be obtained from the module (e.g. pylsp_black). + self.installed_version = programs.get_package_version( + self.distribution_name) + except Exception: + # NOTE: Don't add any exception type here! + # Modules can fail to import in several ways besides + # ImportError + self.installed_version = None + else: + self.installed_version = installed_version + + def check(self): + """Check if dependency is installed""" + if self.required_version: + installed = programs.is_module_installed( + self.modname, + self.required_version, + distribution_name=self.distribution_name + ) + return installed + else: + return True + + def get_installed_version(self): + """Return dependency status (string)""" + if self.check(): + return '%s (%s)' % (self.installed_version, self.OK) + else: + return '%s (%s)' % (self.installed_version, self.NOK) + + def get_status(self): + """Return dependency status (string)""" + if self.check(): + return self.OK + else: + return self.NOK + + +DEPENDENCIES = [] + + +def add(modname, package_name, features, required_version, + installed_version=None, kind=MANDATORY): + """Add Spyder dependency""" + global DEPENDENCIES + for dependency in DEPENDENCIES: + # Avoid showing an unnecessary error when running our tests. + if running_in_ci() and 'spyder_boilerplate' in modname: + continue + + if dependency.modname == modname: + raise ValueError( + f"Dependency has already been registered: {modname}") + + DEPENDENCIES += [Dependency(modname, package_name, features, + required_version, + installed_version, kind)] + + +def check(modname): + """Check if required dependency is installed""" + for dependency in DEPENDENCIES: + if dependency.modname == modname: + return dependency.check() + else: + raise RuntimeError("Unknown dependency %s" % modname) + + +def status(deps=DEPENDENCIES, linesep=os.linesep): + """Return a status of dependencies.""" + maxwidth = 0 + data = [] + + # Find maximum width + for dep in deps: + title = dep.modname + if dep.required_version is not None: + title += ' ' + dep.required_version + + maxwidth = max([maxwidth, len(title)]) + dep_order = {MANDATORY: '0', OPTIONAL: '1', PLUGIN: '2'} + order_dep = {'0': MANDATORY, '1': OPTIONAL, '2': PLUGIN} + data.append([dep_order[dep.kind], title, dep.get_installed_version()]) + + # Construct text and sort by kind and name + maxwidth += 1 + text = "" + prev_order = '-1' + for order, title, version in sorted( + data, key=lambda x: x[0] + x[1].lower()): + if order != prev_order: + name = order_dep[order] + if name == MANDATORY: + text += f'# {name.capitalize()}:{linesep}' + else: + text += f'{linesep}# {name.capitalize()}:{linesep}' + prev_order = order + + text += f'{title.ljust(maxwidth)}: {version}{linesep}' + + # Remove spurious linesep when reporting deps to Github + if not linesep == '
': + text = text[:-1] + + return text + + +def missing_dependencies(): + """Return the status of missing dependencies (if any)""" + missing_deps = [] + for dependency in DEPENDENCIES: + if dependency.kind != OPTIONAL and not dependency.check(): + missing_deps.append(dependency) + + if missing_deps: + return status(deps=missing_deps, linesep='
') + else: + return "" + + +def declare_dependencies(): + for dep in DESCRIPTIONS: + if dep.get('display', True): + add(dep['modname'], dep['package_name'], + dep['features'], dep['required_version'], + kind=dep.get('kind', MANDATORY)) diff --git a/spyder/otherplugins.py b/spyder/otherplugins.py index dba86f52fcc..0e3fae3e5c0 100644 --- a/spyder/otherplugins.py +++ b/spyder/otherplugins.py @@ -1,128 +1,128 @@ -# -*- coding: utf-8 -*- -# -# Copyright © Spyder Project Contributors -# Licensed under the terms of the MIT License -# (see spyder/__init__.py for details) - -""" -Spyder third-party plugins configuration management. -""" - -# Standard library imports -import importlib -import logging -import os -import os.path as osp -import sys -import traceback - -# Local imports -from spyder.config.base import get_conf_path -from spyder.py3compat import to_text_string - - -# Constants -logger = logging.getLogger(__name__) -USER_PLUGIN_DIR = "plugins" -PLUGIN_PREFIX = "spyder_" -IO_PREFIX = PLUGIN_PREFIX + "io_" - - -def get_spyderplugins_mods(io=False): - """Import modules from plugins package and return the list""" - # Create user directory - user_plugin_path = osp.join(get_conf_path(), USER_PLUGIN_DIR) - if not osp.isdir(user_plugin_path): - os.makedirs(user_plugin_path) - - modlist, modnames = [], [] - - # The user plugins directory is given the priority when looking for modules - for plugin_path in [user_plugin_path] + sys.path: - _get_spyderplugins(plugin_path, io, modnames, modlist) - return modlist - - -def _get_spyderplugins(plugin_path, is_io, modnames, modlist): - """Scan the directory `plugin_path` for plugin packages and loads them.""" - if not osp.isdir(plugin_path): - return - - for name in os.listdir(plugin_path): - # This is needed in order to register the spyder_io_hdf5 plugin. - # See spyder-ide/spyder#4487. - # Is this a Spyder plugin? - if not name.startswith(PLUGIN_PREFIX): - continue - - # Ensure right type of plugin - if is_io and not name.startswith(IO_PREFIX): - continue - - # Skip names that end in certain suffixes - forbidden_suffixes = ['dist-info', 'egg.info', 'egg-info', 'egg-link', - 'kernels', 'boilerplate'] - if any([name.endswith(s) for s in forbidden_suffixes]): - continue - - # Import the plugin - _import_plugin(name, plugin_path, modnames, modlist) - - -def _import_plugin(module_name, plugin_path, modnames, modlist): - """Import the plugin `module_name` from `plugin_path`, add it to `modlist` - and adds its name to `modnames`. - """ - if module_name in modnames: - return - try: - # First add a mock module with the LOCALEPATH attribute so that the - # helper method can find the locale on import - mock = _ModuleMock() - mock.LOCALEPATH = osp.join(plugin_path, module_name, 'locale') - sys.modules[module_name] = mock - - if osp.isdir(osp.join(plugin_path, module_name)): - module = _import_module_from_path(module_name, plugin_path) - else: - module = None - - # Then restore the actual loaded module instead of the mock - if module and getattr(module, 'PLUGIN_CLASS', False): - sys.modules[module_name] = module - modlist.append(module) - modnames.append(module_name) - except Exception as e: - sys.stderr.write("ERROR: 3rd party plugin import failed for " - "`{0}`\n".format(module_name)) - traceback.print_exc(file=sys.stderr) - - -def _import_module_from_path(module_name, plugin_path): - """Imports `module_name` from `plugin_path`. - - Return None if no module is found. - """ - module = None - try: - spec = importlib.machinery.PathFinder.find_spec( - module_name, - [plugin_path]) - - if spec: - module = spec.loader.load_module(module_name) - except Exception as err: - debug_message = ("plugin: '{module_name}' load failed with `{err}`" - "").format(module_name=module_name, - err=to_text_string(err)) - logger.debug(debug_message) - - return module - - -class _ModuleMock(): - """This mock module is added to sys.modules on plugin load to add the - location of the LOCALEDATA so that the module loads succesfully. - Once loaded the module is replaced by the actual loaded module object. - """ - pass +# -*- coding: utf-8 -*- +# +# Copyright © Spyder Project Contributors +# Licensed under the terms of the MIT License +# (see spyder/__init__.py for details) + +""" +Spyder third-party plugins configuration management. +""" + +# Standard library imports +import importlib +import logging +import os +import os.path as osp +import sys +import traceback + +# Local imports +from spyder.config.base import get_conf_path +from spyder.py3compat import to_text_string + + +# Constants +logger = logging.getLogger(__name__) +USER_PLUGIN_DIR = "plugins" +PLUGIN_PREFIX = "spyder_" +IO_PREFIX = PLUGIN_PREFIX + "io_" + + +def get_spyderplugins_mods(io=False): + """Import modules from plugins package and return the list""" + # Create user directory + user_plugin_path = osp.join(get_conf_path(), USER_PLUGIN_DIR) + if not osp.isdir(user_plugin_path): + os.makedirs(user_plugin_path) + + modlist, modnames = [], [] + + # The user plugins directory is given the priority when looking for modules + for plugin_path in [user_plugin_path] + sys.path: + _get_spyderplugins(plugin_path, io, modnames, modlist) + return modlist + + +def _get_spyderplugins(plugin_path, is_io, modnames, modlist): + """Scan the directory `plugin_path` for plugin packages and loads them.""" + if not osp.isdir(plugin_path): + return + + for name in os.listdir(plugin_path): + # This is needed in order to register the spyder_io_hdf5 plugin. + # See spyder-ide/spyder#4487. + # Is this a Spyder plugin? + if not name.startswith(PLUGIN_PREFIX): + continue + + # Ensure right type of plugin + if is_io and not name.startswith(IO_PREFIX): + continue + + # Skip names that end in certain suffixes + forbidden_suffixes = ['dist-info', 'egg.info', 'egg-info', 'egg-link', + 'kernels', 'boilerplate'] + if any([name.endswith(s) for s in forbidden_suffixes]): + continue + + # Import the plugin + _import_plugin(name, plugin_path, modnames, modlist) + + +def _import_plugin(module_name, plugin_path, modnames, modlist): + """Import the plugin `module_name` from `plugin_path`, add it to `modlist` + and adds its name to `modnames`. + """ + if module_name in modnames: + return + try: + # First add a mock module with the LOCALEPATH attribute so that the + # helper method can find the locale on import + mock = _ModuleMock() + mock.LOCALEPATH = osp.join(plugin_path, module_name, 'locale') + sys.modules[module_name] = mock + + if osp.isdir(osp.join(plugin_path, module_name)): + module = _import_module_from_path(module_name, plugin_path) + else: + module = None + + # Then restore the actual loaded module instead of the mock + if module and getattr(module, 'PLUGIN_CLASS', False): + sys.modules[module_name] = module + modlist.append(module) + modnames.append(module_name) + except Exception as e: + sys.stderr.write("ERROR: 3rd party plugin import failed for " + "`{0}`\n".format(module_name)) + traceback.print_exc(file=sys.stderr) + + +def _import_module_from_path(module_name, plugin_path): + """Imports `module_name` from `plugin_path`. + + Return None if no module is found. + """ + module = None + try: + spec = importlib.machinery.PathFinder.find_spec( + module_name, + [plugin_path]) + + if spec: + module = spec.loader.load_module(module_name) + except Exception as err: + debug_message = ("plugin: '{module_name}' load failed with `{err}`" + "").format(module_name=module_name, + err=to_text_string(err)) + logger.debug(debug_message) + + return module + + +class _ModuleMock(): + """This mock module is added to sys.modules on plugin load to add the + location of the LOCALEDATA so that the module loads succesfully. + Once loaded the module is replaced by the actual loaded module object. + """ + pass diff --git a/spyder/pil_patch.py b/spyder/pil_patch.py index 0609f056dbd..c661fe2ea81 100644 --- a/spyder/pil_patch.py +++ b/spyder/pil_patch.py @@ -1,61 +1,61 @@ -# -*- coding: utf-8 -*- -# -# Copyright © Spyder Project Contributors -# Licensed under the terms of the MIT License -# (see spyder/__init__.py for details) - -r""" -Patching PIL (Python Imaging Library) to avoid triggering the error: -AccessInit: hash collision: 3 for both 1 and 1 - -This error is occurring because of a bug in the PIL import mechanism. - -How to reproduce this bug in a standard Python interpreter outside Spyder? -By importing PIL by two different mechanisms - -Example on Windows: -=============================================================================== -C:\Python27\Lib\site-packages>python -Python 2.7.2 (default, Jun 12 2011, 15:08:59) [MSC v.1500 32 bit (Intel)] on win32 -Type "help", "copyright", "credits" or "license" for more information. ->>> import Image ->>> from PIL import Image -AccessInit: hash collision: 3 for both 1 and 1 -=============================================================================== - -Another example on Windows (actually that's the same, but this is the exact -case encountered with Spyder when the global working directory is the -site-packages directory): -=============================================================================== -C:\Python27\Lib\site-packages>python -Python 2.7.2 (default, Jun 12 2011, 15:08:59) [MSC v.1500 32 bit (Intel)] on win32 -Type "help", "copyright", "credits" or "license" for more information. ->>> import scipy ->>> from pylab import * -AccessInit: hash collision: 3 for both 1 and 1 -=============================================================================== - -The solution to this fix is the following patch: -=============================================================================== -C:\Python27\Lib\site-packages>python -Python 2.7.2 (default, Jun 12 2011, 15:08:59) [MSC v.1500 32 bit (Intel)] on win -32 -Type "help", "copyright", "credits" or "license" for more information. ->>> import Image ->>> import PIL ->>> PIL.Image = Image ->>> from PIL import Image ->>> -=============================================================================== -""" - -try: - # For Pillow compatibility - from PIL import Image - import PIL - PIL.Image = Image -except ImportError: - # For PIL - import Image - import PIL - PIL.Image = Image +# -*- coding: utf-8 -*- +# +# Copyright © Spyder Project Contributors +# Licensed under the terms of the MIT License +# (see spyder/__init__.py for details) + +r""" +Patching PIL (Python Imaging Library) to avoid triggering the error: +AccessInit: hash collision: 3 for both 1 and 1 + +This error is occurring because of a bug in the PIL import mechanism. + +How to reproduce this bug in a standard Python interpreter outside Spyder? +By importing PIL by two different mechanisms + +Example on Windows: +=============================================================================== +C:\Python27\Lib\site-packages>python +Python 2.7.2 (default, Jun 12 2011, 15:08:59) [MSC v.1500 32 bit (Intel)] on win32 +Type "help", "copyright", "credits" or "license" for more information. +>>> import Image +>>> from PIL import Image +AccessInit: hash collision: 3 for both 1 and 1 +=============================================================================== + +Another example on Windows (actually that's the same, but this is the exact +case encountered with Spyder when the global working directory is the +site-packages directory): +=============================================================================== +C:\Python27\Lib\site-packages>python +Python 2.7.2 (default, Jun 12 2011, 15:08:59) [MSC v.1500 32 bit (Intel)] on win32 +Type "help", "copyright", "credits" or "license" for more information. +>>> import scipy +>>> from pylab import * +AccessInit: hash collision: 3 for both 1 and 1 +=============================================================================== + +The solution to this fix is the following patch: +=============================================================================== +C:\Python27\Lib\site-packages>python +Python 2.7.2 (default, Jun 12 2011, 15:08:59) [MSC v.1500 32 bit (Intel)] on win +32 +Type "help", "copyright", "credits" or "license" for more information. +>>> import Image +>>> import PIL +>>> PIL.Image = Image +>>> from PIL import Image +>>> +=============================================================================== +""" + +try: + # For Pillow compatibility + from PIL import Image + import PIL + PIL.Image = Image +except ImportError: + # For PIL + import Image + import PIL + PIL.Image = Image diff --git a/spyder/plugins/breakpoints/api.py b/spyder/plugins/breakpoints/api.py index fe356dbbe79..c00f01b80e0 100644 --- a/spyder/plugins/breakpoints/api.py +++ b/spyder/plugins/breakpoints/api.py @@ -1,14 +1,14 @@ -# -*- coding: utf-8 -*- -# Copyright (c) 2009- Spyder Project Contributors -# -# Distributed under the terms of the MIT License -# (see spyder/__init__.py for details) - -""" -Breakpoints Plugin API. -""" - -# Local imports -from spyder.plugins.breakpoints.plugin import BreakpointsActions -from spyder.plugins.breakpoints.widgets.main_widget import ( - BreakpointTableViewActions) +# -*- coding: utf-8 -*- +# Copyright (c) 2009- Spyder Project Contributors +# +# Distributed under the terms of the MIT License +# (see spyder/__init__.py for details) + +""" +Breakpoints Plugin API. +""" + +# Local imports +from spyder.plugins.breakpoints.plugin import BreakpointsActions +from spyder.plugins.breakpoints.widgets.main_widget import ( + BreakpointTableViewActions) diff --git a/spyder/plugins/breakpoints/plugin.py b/spyder/plugins/breakpoints/plugin.py index 66ec9b8d0b4..144e5b7ab85 100644 --- a/spyder/plugins/breakpoints/plugin.py +++ b/spyder/plugins/breakpoints/plugin.py @@ -1,209 +1,209 @@ -# -*- coding: utf-8 -*- -# ----------------------------------------------------------------------------- -# Copyright (c) 2009- Spyder Project Contributors -# -# Distributed under the terms of the MIT License -# (see spyder/__init__.py for details) -# ----------------------------------------------------------------------------- - -""" -Breakpoint Plugin. -""" - -# Standard library imports -import os.path as osp - -# Third party imports -from qtpy.QtCore import Signal - -# Local imports -from spyder.api.plugins import Plugins, SpyderDockablePlugin -from spyder.api.plugin_registration.decorators import ( - on_plugin_available, on_plugin_teardown) -from spyder.api.translations import get_translation -from spyder.plugins.breakpoints.widgets.main_widget import BreakpointWidget -from spyder.plugins.mainmenu.api import ApplicationMenus - -# Localization -_ = get_translation('spyder') - - -# --- Constants -# ---------------------------------------------------------------------------- -class BreakpointsActions: - ListBreakpoints = 'list_breakpoints_action' - - -# --- Plugin -# ---------------------------------------------------------------------------- -class Breakpoints(SpyderDockablePlugin): - """ - Breakpoint list Plugin. - """ - NAME = 'breakpoints' - REQUIRES = [Plugins.Editor] - OPTIONAL = [Plugins.MainMenu] - TABIFY = [Plugins.Help] - WIDGET_CLASS = BreakpointWidget - CONF_SECTION = NAME - CONF_FILE = False - - # --- Signals - # ------------------------------------------------------------------------ - sig_clear_all_breakpoints_requested = Signal() - """ - This signal is emitted to send a request to clear all assigned - breakpoints. - """ - - sig_clear_breakpoint_requested = Signal(str, int) - """ - This signal is emitted to send a request to clear a single breakpoint. - - Parameters - ---------- - filename: str - The path to filename containing the breakpoint. - line_number: int - The line number of the breakpoint. - """ - - sig_edit_goto_requested = Signal(str, int, str) - """ - Send a request to open a file in the editor at a given row and word. - - Parameters - ---------- - filename: str - The path to the filename containing the breakpoint. - line_number: int - The line number of the breakpoint. - word: str - Text `word` to select on given `line_number`. - """ - - sig_conditional_breakpoint_requested = Signal() - """ - Send a request to set/edit a condition on a single selected breakpoint. - """ - - # --- SpyderDockablePlugin API - # ------------------------------------------------------------------------ - @staticmethod - def get_name(): - return _("Breakpoints") - - def get_description(self): - return _("Manage code breakpoints in a unified pane.") - - def get_icon(self): - return self.create_icon('breakpoints') - - def on_initialize(self): - widget = self.get_widget() - - widget.sig_clear_all_breakpoints_requested.connect( - self.sig_clear_all_breakpoints_requested) - widget.sig_clear_breakpoint_requested.connect( - self.sig_clear_breakpoint_requested) - widget.sig_edit_goto_requested.connect(self.sig_edit_goto_requested) - widget.sig_conditional_breakpoint_requested.connect( - self.sig_conditional_breakpoint_requested) - - self.create_action( - BreakpointsActions.ListBreakpoints, - _("List breakpoints"), - triggered=lambda: self.switch_to_plugin(), - icon=self.get_icon(), - ) - - @on_plugin_available(plugin=Plugins.Editor) - def on_editor_available(self): - widget = self.get_widget() - editor = self.get_plugin(Plugins.Editor) - list_action = self.get_action(BreakpointsActions.ListBreakpoints) - - # TODO: change name of this signal on editor - editor.breakpoints_saved.connect(self.set_data) - widget.sig_clear_all_breakpoints_requested.connect( - editor.clear_all_breakpoints) - widget.sig_clear_breakpoint_requested.connect(editor.clear_breakpoint) - widget.sig_edit_goto_requested.connect(editor.load) - widget.sig_conditional_breakpoint_requested.connect( - editor.set_or_edit_conditional_breakpoint) - - # TODO: Fix location once the sections are defined - editor.pythonfile_dependent_actions += [list_action] - - @on_plugin_available(plugin=Plugins.MainMenu) - def on_main_menu_available(self): - mainmenu = self.get_plugin(Plugins.MainMenu) - list_action = self.get_action(BreakpointsActions.ListBreakpoints) - mainmenu.add_item_to_application_menu( - list_action, menu_id=ApplicationMenus.Debug) - - @on_plugin_teardown(plugin=Plugins.Editor) - def on_editor_teardown(self): - widget = self.get_widget() - editor = self.get_plugin(Plugins.Editor) - list_action = self.get_action(BreakpointsActions.ListBreakpoints) - - editor.breakpoints_saved.disconnect(self.set_data) - widget.sig_clear_all_breakpoints_requested.disconnect( - editor.clear_all_breakpoints) - widget.sig_clear_breakpoint_requested.disconnect( - editor.clear_breakpoint) - widget.sig_edit_goto_requested.disconnect(editor.load) - widget.sig_conditional_breakpoint_requested.disconnect( - editor.set_or_edit_conditional_breakpoint) - - editor.pythonfile_dependent_actions.remove(list_action) - - @on_plugin_teardown(plugin=Plugins.MainMenu) - def on_main_menu_teardown(self): - mainmenu = self.get_plugin(Plugins.MainMenu) - mainmenu.remove_item_from_application_menu( - BreakpointsActions.ListBreakpoints, menu_id=ApplicationMenus.Debug) - - # --- Private API - # ------------------------------------------------------------------------ - def _load_data(self): - """ - Load breakpoint data from configuration file. - """ - breakpoints_dict = self.get_conf( - 'breakpoints', - default={}, - section='run', - ) - for filename in list(breakpoints_dict.keys()): - if not osp.isfile(filename): - breakpoints_dict.pop(filename) - continue - # Make sure we don't have the same file under different names - new_filename = osp.normcase(filename) - if new_filename != filename: - bp = breakpoints_dict.pop(filename) - if new_filename in breakpoints_dict: - breakpoints_dict[new_filename].extend(bp) - else: - breakpoints_dict[new_filename] = bp - - return breakpoints_dict - - # --- Public API - # ------------------------------------------------------------------------ - def set_data(self, data=None): - """ - Set breakpoint data on widget. - - Parameters - ---------- - data: dict, optional - Breakpoint data to use. If None, data from the configuration - will be loaded. Default is None. - """ - if data is None: - data = self._load_data() - - self.get_widget().set_data(data) +# -*- coding: utf-8 -*- +# ----------------------------------------------------------------------------- +# Copyright (c) 2009- Spyder Project Contributors +# +# Distributed under the terms of the MIT License +# (see spyder/__init__.py for details) +# ----------------------------------------------------------------------------- + +""" +Breakpoint Plugin. +""" + +# Standard library imports +import os.path as osp + +# Third party imports +from qtpy.QtCore import Signal + +# Local imports +from spyder.api.plugins import Plugins, SpyderDockablePlugin +from spyder.api.plugin_registration.decorators import ( + on_plugin_available, on_plugin_teardown) +from spyder.api.translations import get_translation +from spyder.plugins.breakpoints.widgets.main_widget import BreakpointWidget +from spyder.plugins.mainmenu.api import ApplicationMenus + +# Localization +_ = get_translation('spyder') + + +# --- Constants +# ---------------------------------------------------------------------------- +class BreakpointsActions: + ListBreakpoints = 'list_breakpoints_action' + + +# --- Plugin +# ---------------------------------------------------------------------------- +class Breakpoints(SpyderDockablePlugin): + """ + Breakpoint list Plugin. + """ + NAME = 'breakpoints' + REQUIRES = [Plugins.Editor] + OPTIONAL = [Plugins.MainMenu] + TABIFY = [Plugins.Help] + WIDGET_CLASS = BreakpointWidget + CONF_SECTION = NAME + CONF_FILE = False + + # --- Signals + # ------------------------------------------------------------------------ + sig_clear_all_breakpoints_requested = Signal() + """ + This signal is emitted to send a request to clear all assigned + breakpoints. + """ + + sig_clear_breakpoint_requested = Signal(str, int) + """ + This signal is emitted to send a request to clear a single breakpoint. + + Parameters + ---------- + filename: str + The path to filename containing the breakpoint. + line_number: int + The line number of the breakpoint. + """ + + sig_edit_goto_requested = Signal(str, int, str) + """ + Send a request to open a file in the editor at a given row and word. + + Parameters + ---------- + filename: str + The path to the filename containing the breakpoint. + line_number: int + The line number of the breakpoint. + word: str + Text `word` to select on given `line_number`. + """ + + sig_conditional_breakpoint_requested = Signal() + """ + Send a request to set/edit a condition on a single selected breakpoint. + """ + + # --- SpyderDockablePlugin API + # ------------------------------------------------------------------------ + @staticmethod + def get_name(): + return _("Breakpoints") + + def get_description(self): + return _("Manage code breakpoints in a unified pane.") + + def get_icon(self): + return self.create_icon('breakpoints') + + def on_initialize(self): + widget = self.get_widget() + + widget.sig_clear_all_breakpoints_requested.connect( + self.sig_clear_all_breakpoints_requested) + widget.sig_clear_breakpoint_requested.connect( + self.sig_clear_breakpoint_requested) + widget.sig_edit_goto_requested.connect(self.sig_edit_goto_requested) + widget.sig_conditional_breakpoint_requested.connect( + self.sig_conditional_breakpoint_requested) + + self.create_action( + BreakpointsActions.ListBreakpoints, + _("List breakpoints"), + triggered=lambda: self.switch_to_plugin(), + icon=self.get_icon(), + ) + + @on_plugin_available(plugin=Plugins.Editor) + def on_editor_available(self): + widget = self.get_widget() + editor = self.get_plugin(Plugins.Editor) + list_action = self.get_action(BreakpointsActions.ListBreakpoints) + + # TODO: change name of this signal on editor + editor.breakpoints_saved.connect(self.set_data) + widget.sig_clear_all_breakpoints_requested.connect( + editor.clear_all_breakpoints) + widget.sig_clear_breakpoint_requested.connect(editor.clear_breakpoint) + widget.sig_edit_goto_requested.connect(editor.load) + widget.sig_conditional_breakpoint_requested.connect( + editor.set_or_edit_conditional_breakpoint) + + # TODO: Fix location once the sections are defined + editor.pythonfile_dependent_actions += [list_action] + + @on_plugin_available(plugin=Plugins.MainMenu) + def on_main_menu_available(self): + mainmenu = self.get_plugin(Plugins.MainMenu) + list_action = self.get_action(BreakpointsActions.ListBreakpoints) + mainmenu.add_item_to_application_menu( + list_action, menu_id=ApplicationMenus.Debug) + + @on_plugin_teardown(plugin=Plugins.Editor) + def on_editor_teardown(self): + widget = self.get_widget() + editor = self.get_plugin(Plugins.Editor) + list_action = self.get_action(BreakpointsActions.ListBreakpoints) + + editor.breakpoints_saved.disconnect(self.set_data) + widget.sig_clear_all_breakpoints_requested.disconnect( + editor.clear_all_breakpoints) + widget.sig_clear_breakpoint_requested.disconnect( + editor.clear_breakpoint) + widget.sig_edit_goto_requested.disconnect(editor.load) + widget.sig_conditional_breakpoint_requested.disconnect( + editor.set_or_edit_conditional_breakpoint) + + editor.pythonfile_dependent_actions.remove(list_action) + + @on_plugin_teardown(plugin=Plugins.MainMenu) + def on_main_menu_teardown(self): + mainmenu = self.get_plugin(Plugins.MainMenu) + mainmenu.remove_item_from_application_menu( + BreakpointsActions.ListBreakpoints, menu_id=ApplicationMenus.Debug) + + # --- Private API + # ------------------------------------------------------------------------ + def _load_data(self): + """ + Load breakpoint data from configuration file. + """ + breakpoints_dict = self.get_conf( + 'breakpoints', + default={}, + section='run', + ) + for filename in list(breakpoints_dict.keys()): + if not osp.isfile(filename): + breakpoints_dict.pop(filename) + continue + # Make sure we don't have the same file under different names + new_filename = osp.normcase(filename) + if new_filename != filename: + bp = breakpoints_dict.pop(filename) + if new_filename in breakpoints_dict: + breakpoints_dict[new_filename].extend(bp) + else: + breakpoints_dict[new_filename] = bp + + return breakpoints_dict + + # --- Public API + # ------------------------------------------------------------------------ + def set_data(self, data=None): + """ + Set breakpoint data on widget. + + Parameters + ---------- + data: dict, optional + Breakpoint data to use. If None, data from the configuration + will be loaded. Default is None. + """ + if data is None: + data = self._load_data() + + self.get_widget().set_data(data) diff --git a/spyder/plugins/breakpoints/widgets/main_widget.py b/spyder/plugins/breakpoints/widgets/main_widget.py index 9126d037545..2596e5f5776 100644 --- a/spyder/plugins/breakpoints/widgets/main_widget.py +++ b/spyder/plugins/breakpoints/widgets/main_widget.py @@ -1,430 +1,430 @@ -# -*- coding: utf-8 -*- -# -# Copyright © Spyder Project Contributors -# based loosley on pylintgui.py by Pierre Raybaut -# Licensed under the terms of the MIT License -# (see spyder/__init__.py for details) - -""" -Breakpoint widget. -""" - -# pylint: disable=C0103 -# pylint: disable=R0903 -# pylint: disable=R0911 -# pylint: disable=R0201 - -# Standard library imports -import sys - -# Third party imports -from qtpy import PYQT5 -from qtpy.compat import to_qvariant -from qtpy.QtCore import QAbstractTableModel, QModelIndex, Qt, Signal -from qtpy.QtWidgets import QItemDelegate, QTableView, QVBoxLayout - -# Local imports -from spyder.api.translations import get_translation -from spyder.api.widgets.main_widget import (PluginMainWidgetMenus, - PluginMainWidget) -from spyder.api.widgets.mixins import SpyderWidgetMixin -from spyder.utils.sourcecode import disambiguate_fname - - -# Localization -_ = get_translation('spyder') - - -# --- Constants -# ---------------------------------------------------------------------------- -COLUMN_COUNT = 4 -EXTRA_COLUMNS = 1 -COL_FILE, COL_LINE, COL_CONDITION, COL_BLANK, COL_FULL = list( - range(COLUMN_COUNT + EXTRA_COLUMNS)) -COLUMN_HEADERS = (_("File"), _("Line"), _("Condition"), ("")) - - -class BreakpointTableViewActions: - # Triggers - ClearAllBreakpoints = 'clear_all_breakpoints_action' - ClearBreakpoint = 'clear_breakpoint_action' - EditBreakpoint = 'edit_breakpoint_action' - - -# --- Widgets -# ---------------------------------------------------------------------------- -class BreakpointTableModel(QAbstractTableModel): - """ - Table model for breakpoints dictionary. - """ - - def __init__(self, parent, data): - super().__init__(parent) - - self._data = {} if data is None else data - self.breakpoints = None - - self.set_data(self._data) - - def set_data(self, data): - """ - Set model data. - - Parameters - ---------- - data: dict - Breakpoint data to use. - """ - self._data = data - self.breakpoints = [] - files = [] - # Generate list of filenames with active breakpoints - for key in data: - if data[key] and key not in files: - files.append(key) - - # Insert items - for key in files: - for item in data[key]: - # Store full file name in last position, which is not shown - self.breakpoints.append((disambiguate_fname(files, key), - item[0], item[1], "", key)) - self.reset() - - def rowCount(self, qindex=QModelIndex()): - """ - Array row number. - """ - return len(self.breakpoints) - - def columnCount(self, qindex=QModelIndex()): - """ - Array column count. - """ - return COLUMN_COUNT - - def sort(self, column, order=Qt.DescendingOrder): - """ - Overriding sort method. - """ - if column == COL_FILE: - self.breakpoints.sort(key=lambda breakp: int(breakp[COL_LINE])) - self.breakpoints.sort(key=lambda breakp: breakp[COL_FILE]) - elif column == COL_LINE: - pass - elif column == COL_CONDITION: - pass - elif column == COL_BLANK: - pass - - self.reset() - - def headerData(self, section, orientation, role=Qt.DisplayRole): - """ - Overriding method headerData. - """ - if role != Qt.DisplayRole: - return to_qvariant() - - i_column = int(section) - if orientation == Qt.Horizontal: - return to_qvariant(COLUMN_HEADERS[i_column]) - else: - return to_qvariant() - - def get_value(self, index): - """ - Return current value. - """ - return self.breakpoints[index.row()][index.column()] - - def data(self, index, role=Qt.DisplayRole): - """ - Return data at table index. - """ - if not index.isValid(): - return to_qvariant() - - if role == Qt.DisplayRole: - value = self.get_value(index) - return to_qvariant(value) - elif role == Qt.TextAlignmentRole: - if index.column() == COL_LINE: - # Align line number right - return to_qvariant(int(Qt.AlignRight | Qt.AlignVCenter)) - else: - return to_qvariant(int(Qt.AlignLeft | Qt.AlignVCenter)) - elif role == Qt.ToolTipRole: - if index.column() == COL_FILE: - # Return full file name (in last position) - value = self.breakpoints[index.row()][COL_FULL] - return to_qvariant(value) - else: - return to_qvariant() - - def reset(self): - self.beginResetModel() - self.endResetModel() - - -class BreakpointDelegate(QItemDelegate): - - def __init__(self, parent=None): - super().__init__(parent) - - -class BreakpointTableView(QTableView, SpyderWidgetMixin): - """ - Table to display code breakpoints. - """ - - # Signals - sig_clear_all_breakpoints_requested = Signal() - sig_clear_breakpoint_requested = Signal(str, int) - sig_edit_goto_requested = Signal(str, int, str) - sig_conditional_breakpoint_requested = Signal() - - def __init__(self, parent, data): - if PYQT5: - super().__init__(parent, class_parent=parent) - else: - QTableView.__init__(self, parent) - SpyderWidgetMixin.__init__(self, class_parent=parent) - - # Widgets - self.model = BreakpointTableModel(self, data) - self.delegate = BreakpointDelegate(self) - - # Setup - self.setSortingEnabled(False) - self.setSelectionBehavior(self.SelectRows) - self.setSelectionMode(self.SingleSelection) - self.setModel(self.model) - self.setItemDelegate(self.delegate) - self.adjust_columns() - self.columnAt(0) - self.horizontalHeader().setStretchLastSection(True) - - # --- SpyderWidgetMixin API - # ------------------------------------------------------------------------ - def setup(self): - clear_all_action = self.create_action( - BreakpointTableViewActions.ClearAllBreakpoints, - _("Clear breakpoints in all files"), - triggered=self.sig_clear_all_breakpoints_requested, - ) - clear_action = self.create_action( - BreakpointTableViewActions.ClearBreakpoint, - _("Clear selected breakpoint"), - triggered=self.clear_breakpoints, - ) - edit_action = self.create_action( - BreakpointTableViewActions.EditBreakpoint, - _("Edit selected breakpoint"), - triggered=self.edit_breakpoints, - ) - - self.popup_menu = self.create_menu(PluginMainWidgetMenus.Context) - for item in [clear_all_action, clear_action, edit_action]: - self.add_item_to_menu(item, menu=self.popup_menu) - - # --- Qt overrides - # ------------------------------------------------------------------------ - def contextMenuEvent(self, event): - """ - Override Qt method. - """ - c_row = self.indexAt(event.pos()).row() - enabled = bool(self.model.breakpoints) and c_row is not None - clear_action = self.get_action( - BreakpointTableViewActions.ClearBreakpoint) - edit_action = self.get_action( - BreakpointTableViewActions.EditBreakpoint) - clear_action.setEnabled(enabled) - edit_action.setEnabled(enabled) - - self.popup_menu.popup(event.globalPos()) - event.accept() - - def mouseDoubleClickEvent(self, event): - """ - Override Qt method. - """ - index_clicked = self.indexAt(event.pos()) - if self.model.breakpoints: - c_row = index_clicked.row() - filename = self.model.breakpoints[c_row][COL_FULL] - line_number_str = self.model.breakpoints[c_row][COL_LINE] - - self.sig_edit_goto_requested.emit( - filename, int(line_number_str), '') - - if index_clicked.column() == COL_CONDITION: - self.sig_conditional_breakpoint_requested.emit() - - # --- API - # ------------------------------------------------------------------------ - def set_data(self, data): - """ - Set the model breakpoint data dictionary. - - Parameters - ---------- - data: dict - Breakpoint data to use. - """ - self.model.set_data(data) - self.adjust_columns() - self.sortByColumn(COL_FILE, Qt.DescendingOrder) - - def adjust_columns(self): - """ - Resize three first columns to contents. - """ - for col in range(COLUMN_COUNT - 1): - self.resizeColumnToContents(col) - - def clear_breakpoints(self): - """ - Clear selected row breakpoint. - """ - rows = self.selectionModel().selectedRows() - if rows and self.model.breakpoints: - c_row = rows[0].row() - filename = self.model.breakpoints[c_row][COL_FULL] - lineno = int(self.model.breakpoints[c_row][COL_LINE]) - - self.sig_clear_breakpoint_requested.emit(filename, lineno) - - def edit_breakpoints(self): - """ - Edit selected row breakpoint condition. - """ - rows = self.selectionModel().selectedRows() - if rows and self.model.breakpoints: - c_row = rows[0].row() - filename = self.model.breakpoints[c_row][COL_FULL] - lineno = int(self.model.breakpoints[c_row][COL_LINE]) - - self.sig_edit_goto_requested.emit(filename, lineno, '') - self.sig_conditional_breakpoint_requested.emit() - - -class BreakpointWidget(PluginMainWidget): - """ - Breakpoints widget. - """ - - # --- Signals - # ------------------------------------------------------------------------ - sig_clear_all_breakpoints_requested = Signal() - """ - This signal is emitted to send a request to clear all assigned - breakpoints. - """ - - sig_clear_breakpoint_requested = Signal(str, int) - """ - This signal is emitted to send a request to clear a single breakpoint. - - Parameters - ---------- - filename: str - The path to filename cotaining the breakpoint. - line_number: int - The line number of the breakpoint. - """ - - sig_edit_goto_requested = Signal(str, int, str) - """ - Send a request to open a file in the editor at a given row and word. - - Parameters - ---------- - filename: str - The path to the filename containing the breakpoint. - line_number: int - The line number of the breakpoint. - word: str - Text `word` to select on given `line_number`. - """ - - sig_conditional_breakpoint_requested = Signal() - """ - Send a request to set/edit a condition on a single selected breakpoint. - """ - - def __init__(self, name=None, plugin=None, parent=None): - super().__init__(name, plugin, parent=parent) - - # Widgets - self.breakpoints_table = BreakpointTableView(self, {}) - - # Layout - layout = QVBoxLayout() - layout.addWidget(self.breakpoints_table) - self.setLayout(layout) - - # Signals - bpt = self.breakpoints_table - bpt.sig_clear_all_breakpoints_requested.connect( - self.sig_clear_all_breakpoints_requested) - bpt.sig_clear_breakpoint_requested.connect( - self.sig_clear_breakpoint_requested) - bpt.sig_edit_goto_requested.connect(self.sig_edit_goto_requested) - bpt.sig_conditional_breakpoint_requested.connect( - self.sig_conditional_breakpoint_requested) - - # --- PluginMainWidget API - # ------------------------------------------------------------------------ - def get_title(self): - return _('Breakpoints') - - def get_focus_widget(self): - return self.breakpoints_table - - def setup(self): - self.breakpoints_table.setup() - - def update_actions(self): - rows = self.breakpoints_table.selectionModel().selectedRows() - c_row = rows[0] if rows else None - - enabled = (bool(self.breakpoints_table.model.breakpoints) - and c_row is not None) - clear_action = self.get_action( - BreakpointTableViewActions.ClearBreakpoint) - edit_action = self.get_action( - BreakpointTableViewActions.EditBreakpoint) - clear_action.setEnabled(enabled) - edit_action.setEnabled(enabled) - - # --- Public API - # ------------------------------------------------------------------------ - def set_data(self, data): - """ - Set breakpoint data on widget. - - Parameters - ---------- - data: dict - Breakpoint data to use. - """ - self.breakpoints_table.set_data(data) - - -# ============================================================================= -# Tests -# ============================================================================= -def test(): - """Run breakpoint widget test.""" - from spyder.utils.qthelpers import qapplication - - app = qapplication() - widget = BreakpointWidget() - widget.show() - sys.exit(app.exec_()) - - -if __name__ == '__main__': - test() +# -*- coding: utf-8 -*- +# +# Copyright © Spyder Project Contributors +# based loosley on pylintgui.py by Pierre Raybaut +# Licensed under the terms of the MIT License +# (see spyder/__init__.py for details) + +""" +Breakpoint widget. +""" + +# pylint: disable=C0103 +# pylint: disable=R0903 +# pylint: disable=R0911 +# pylint: disable=R0201 + +# Standard library imports +import sys + +# Third party imports +from qtpy import PYQT5 +from qtpy.compat import to_qvariant +from qtpy.QtCore import QAbstractTableModel, QModelIndex, Qt, Signal +from qtpy.QtWidgets import QItemDelegate, QTableView, QVBoxLayout + +# Local imports +from spyder.api.translations import get_translation +from spyder.api.widgets.main_widget import (PluginMainWidgetMenus, + PluginMainWidget) +from spyder.api.widgets.mixins import SpyderWidgetMixin +from spyder.utils.sourcecode import disambiguate_fname + + +# Localization +_ = get_translation('spyder') + + +# --- Constants +# ---------------------------------------------------------------------------- +COLUMN_COUNT = 4 +EXTRA_COLUMNS = 1 +COL_FILE, COL_LINE, COL_CONDITION, COL_BLANK, COL_FULL = list( + range(COLUMN_COUNT + EXTRA_COLUMNS)) +COLUMN_HEADERS = (_("File"), _("Line"), _("Condition"), ("")) + + +class BreakpointTableViewActions: + # Triggers + ClearAllBreakpoints = 'clear_all_breakpoints_action' + ClearBreakpoint = 'clear_breakpoint_action' + EditBreakpoint = 'edit_breakpoint_action' + + +# --- Widgets +# ---------------------------------------------------------------------------- +class BreakpointTableModel(QAbstractTableModel): + """ + Table model for breakpoints dictionary. + """ + + def __init__(self, parent, data): + super().__init__(parent) + + self._data = {} if data is None else data + self.breakpoints = None + + self.set_data(self._data) + + def set_data(self, data): + """ + Set model data. + + Parameters + ---------- + data: dict + Breakpoint data to use. + """ + self._data = data + self.breakpoints = [] + files = [] + # Generate list of filenames with active breakpoints + for key in data: + if data[key] and key not in files: + files.append(key) + + # Insert items + for key in files: + for item in data[key]: + # Store full file name in last position, which is not shown + self.breakpoints.append((disambiguate_fname(files, key), + item[0], item[1], "", key)) + self.reset() + + def rowCount(self, qindex=QModelIndex()): + """ + Array row number. + """ + return len(self.breakpoints) + + def columnCount(self, qindex=QModelIndex()): + """ + Array column count. + """ + return COLUMN_COUNT + + def sort(self, column, order=Qt.DescendingOrder): + """ + Overriding sort method. + """ + if column == COL_FILE: + self.breakpoints.sort(key=lambda breakp: int(breakp[COL_LINE])) + self.breakpoints.sort(key=lambda breakp: breakp[COL_FILE]) + elif column == COL_LINE: + pass + elif column == COL_CONDITION: + pass + elif column == COL_BLANK: + pass + + self.reset() + + def headerData(self, section, orientation, role=Qt.DisplayRole): + """ + Overriding method headerData. + """ + if role != Qt.DisplayRole: + return to_qvariant() + + i_column = int(section) + if orientation == Qt.Horizontal: + return to_qvariant(COLUMN_HEADERS[i_column]) + else: + return to_qvariant() + + def get_value(self, index): + """ + Return current value. + """ + return self.breakpoints[index.row()][index.column()] + + def data(self, index, role=Qt.DisplayRole): + """ + Return data at table index. + """ + if not index.isValid(): + return to_qvariant() + + if role == Qt.DisplayRole: + value = self.get_value(index) + return to_qvariant(value) + elif role == Qt.TextAlignmentRole: + if index.column() == COL_LINE: + # Align line number right + return to_qvariant(int(Qt.AlignRight | Qt.AlignVCenter)) + else: + return to_qvariant(int(Qt.AlignLeft | Qt.AlignVCenter)) + elif role == Qt.ToolTipRole: + if index.column() == COL_FILE: + # Return full file name (in last position) + value = self.breakpoints[index.row()][COL_FULL] + return to_qvariant(value) + else: + return to_qvariant() + + def reset(self): + self.beginResetModel() + self.endResetModel() + + +class BreakpointDelegate(QItemDelegate): + + def __init__(self, parent=None): + super().__init__(parent) + + +class BreakpointTableView(QTableView, SpyderWidgetMixin): + """ + Table to display code breakpoints. + """ + + # Signals + sig_clear_all_breakpoints_requested = Signal() + sig_clear_breakpoint_requested = Signal(str, int) + sig_edit_goto_requested = Signal(str, int, str) + sig_conditional_breakpoint_requested = Signal() + + def __init__(self, parent, data): + if PYQT5: + super().__init__(parent, class_parent=parent) + else: + QTableView.__init__(self, parent) + SpyderWidgetMixin.__init__(self, class_parent=parent) + + # Widgets + self.model = BreakpointTableModel(self, data) + self.delegate = BreakpointDelegate(self) + + # Setup + self.setSortingEnabled(False) + self.setSelectionBehavior(self.SelectRows) + self.setSelectionMode(self.SingleSelection) + self.setModel(self.model) + self.setItemDelegate(self.delegate) + self.adjust_columns() + self.columnAt(0) + self.horizontalHeader().setStretchLastSection(True) + + # --- SpyderWidgetMixin API + # ------------------------------------------------------------------------ + def setup(self): + clear_all_action = self.create_action( + BreakpointTableViewActions.ClearAllBreakpoints, + _("Clear breakpoints in all files"), + triggered=self.sig_clear_all_breakpoints_requested, + ) + clear_action = self.create_action( + BreakpointTableViewActions.ClearBreakpoint, + _("Clear selected breakpoint"), + triggered=self.clear_breakpoints, + ) + edit_action = self.create_action( + BreakpointTableViewActions.EditBreakpoint, + _("Edit selected breakpoint"), + triggered=self.edit_breakpoints, + ) + + self.popup_menu = self.create_menu(PluginMainWidgetMenus.Context) + for item in [clear_all_action, clear_action, edit_action]: + self.add_item_to_menu(item, menu=self.popup_menu) + + # --- Qt overrides + # ------------------------------------------------------------------------ + def contextMenuEvent(self, event): + """ + Override Qt method. + """ + c_row = self.indexAt(event.pos()).row() + enabled = bool(self.model.breakpoints) and c_row is not None + clear_action = self.get_action( + BreakpointTableViewActions.ClearBreakpoint) + edit_action = self.get_action( + BreakpointTableViewActions.EditBreakpoint) + clear_action.setEnabled(enabled) + edit_action.setEnabled(enabled) + + self.popup_menu.popup(event.globalPos()) + event.accept() + + def mouseDoubleClickEvent(self, event): + """ + Override Qt method. + """ + index_clicked = self.indexAt(event.pos()) + if self.model.breakpoints: + c_row = index_clicked.row() + filename = self.model.breakpoints[c_row][COL_FULL] + line_number_str = self.model.breakpoints[c_row][COL_LINE] + + self.sig_edit_goto_requested.emit( + filename, int(line_number_str), '') + + if index_clicked.column() == COL_CONDITION: + self.sig_conditional_breakpoint_requested.emit() + + # --- API + # ------------------------------------------------------------------------ + def set_data(self, data): + """ + Set the model breakpoint data dictionary. + + Parameters + ---------- + data: dict + Breakpoint data to use. + """ + self.model.set_data(data) + self.adjust_columns() + self.sortByColumn(COL_FILE, Qt.DescendingOrder) + + def adjust_columns(self): + """ + Resize three first columns to contents. + """ + for col in range(COLUMN_COUNT - 1): + self.resizeColumnToContents(col) + + def clear_breakpoints(self): + """ + Clear selected row breakpoint. + """ + rows = self.selectionModel().selectedRows() + if rows and self.model.breakpoints: + c_row = rows[0].row() + filename = self.model.breakpoints[c_row][COL_FULL] + lineno = int(self.model.breakpoints[c_row][COL_LINE]) + + self.sig_clear_breakpoint_requested.emit(filename, lineno) + + def edit_breakpoints(self): + """ + Edit selected row breakpoint condition. + """ + rows = self.selectionModel().selectedRows() + if rows and self.model.breakpoints: + c_row = rows[0].row() + filename = self.model.breakpoints[c_row][COL_FULL] + lineno = int(self.model.breakpoints[c_row][COL_LINE]) + + self.sig_edit_goto_requested.emit(filename, lineno, '') + self.sig_conditional_breakpoint_requested.emit() + + +class BreakpointWidget(PluginMainWidget): + """ + Breakpoints widget. + """ + + # --- Signals + # ------------------------------------------------------------------------ + sig_clear_all_breakpoints_requested = Signal() + """ + This signal is emitted to send a request to clear all assigned + breakpoints. + """ + + sig_clear_breakpoint_requested = Signal(str, int) + """ + This signal is emitted to send a request to clear a single breakpoint. + + Parameters + ---------- + filename: str + The path to filename cotaining the breakpoint. + line_number: int + The line number of the breakpoint. + """ + + sig_edit_goto_requested = Signal(str, int, str) + """ + Send a request to open a file in the editor at a given row and word. + + Parameters + ---------- + filename: str + The path to the filename containing the breakpoint. + line_number: int + The line number of the breakpoint. + word: str + Text `word` to select on given `line_number`. + """ + + sig_conditional_breakpoint_requested = Signal() + """ + Send a request to set/edit a condition on a single selected breakpoint. + """ + + def __init__(self, name=None, plugin=None, parent=None): + super().__init__(name, plugin, parent=parent) + + # Widgets + self.breakpoints_table = BreakpointTableView(self, {}) + + # Layout + layout = QVBoxLayout() + layout.addWidget(self.breakpoints_table) + self.setLayout(layout) + + # Signals + bpt = self.breakpoints_table + bpt.sig_clear_all_breakpoints_requested.connect( + self.sig_clear_all_breakpoints_requested) + bpt.sig_clear_breakpoint_requested.connect( + self.sig_clear_breakpoint_requested) + bpt.sig_edit_goto_requested.connect(self.sig_edit_goto_requested) + bpt.sig_conditional_breakpoint_requested.connect( + self.sig_conditional_breakpoint_requested) + + # --- PluginMainWidget API + # ------------------------------------------------------------------------ + def get_title(self): + return _('Breakpoints') + + def get_focus_widget(self): + return self.breakpoints_table + + def setup(self): + self.breakpoints_table.setup() + + def update_actions(self): + rows = self.breakpoints_table.selectionModel().selectedRows() + c_row = rows[0] if rows else None + + enabled = (bool(self.breakpoints_table.model.breakpoints) + and c_row is not None) + clear_action = self.get_action( + BreakpointTableViewActions.ClearBreakpoint) + edit_action = self.get_action( + BreakpointTableViewActions.EditBreakpoint) + clear_action.setEnabled(enabled) + edit_action.setEnabled(enabled) + + # --- Public API + # ------------------------------------------------------------------------ + def set_data(self, data): + """ + Set breakpoint data on widget. + + Parameters + ---------- + data: dict + Breakpoint data to use. + """ + self.breakpoints_table.set_data(data) + + +# ============================================================================= +# Tests +# ============================================================================= +def test(): + """Run breakpoint widget test.""" + from spyder.utils.qthelpers import qapplication + + app = qapplication() + widget = BreakpointWidget() + widget.show() + sys.exit(app.exec_()) + + +if __name__ == '__main__': + test() diff --git a/spyder/plugins/console/api.py b/spyder/plugins/console/api.py index a5ee23322e3..c6e0c42ef28 100644 --- a/spyder/plugins/console/api.py +++ b/spyder/plugins/console/api.py @@ -1,18 +1,18 @@ -# -*- coding: utf-8 -*- -# -# Copyright © Spyder Project Contributors -# Licensed under the terms of the MIT License -# (see spyder/__init__.py for details) - -""" -Console Plugin API. -""" - -# Local imports -from spyder.plugins.console.widgets.main_widget import ( - ConsoleWidgetActions, ConsoleWidgetInternalSettingsSubMenuSections, - ConsoleWidgetMenus, ConsoleWidgetOptionsMenuSections) - - -class ConsoleActions: - SpyderReportAction = "spyder_report_action" +# -*- coding: utf-8 -*- +# +# Copyright © Spyder Project Contributors +# Licensed under the terms of the MIT License +# (see spyder/__init__.py for details) + +""" +Console Plugin API. +""" + +# Local imports +from spyder.plugins.console.widgets.main_widget import ( + ConsoleWidgetActions, ConsoleWidgetInternalSettingsSubMenuSections, + ConsoleWidgetMenus, ConsoleWidgetOptionsMenuSections) + + +class ConsoleActions: + SpyderReportAction = "spyder_report_action" diff --git a/spyder/plugins/console/plugin.py b/spyder/plugins/console/plugin.py index 066f6687657..baea3227a03 100644 --- a/spyder/plugins/console/plugin.py +++ b/spyder/plugins/console/plugin.py @@ -1,269 +1,269 @@ -# -*- coding: utf-8 -*- -# -# Copyright © Spyder Project Contributors -# Licensed under the terms of the MIT License -# (see spyder/__init__.py for details) - -""" -Internal Console Plugin. -""" - -# Standard library imports -import logging - -# Third party imports -from qtpy.QtCore import Signal, Slot -from qtpy.QtGui import QIcon - -# Local imports -from spyder.api.plugins import Plugins, SpyderDockablePlugin -from spyder.api.plugin_registration.decorators import ( - on_plugin_available, on_plugin_teardown) -from spyder.api.translations import get_translation -from spyder.config.base import DEV -from spyder.plugins.console.widgets.main_widget import ( - ConsoleWidget, ConsoleWidgetActions) -from spyder.plugins.mainmenu.api import ApplicationMenus, FileMenuSections - -# Localization -_ = get_translation('spyder') - -# Logging -logger = logging.getLogger(__name__) - - -class Console(SpyderDockablePlugin): - """ - Console widget - """ - NAME = 'internal_console' - WIDGET_CLASS = ConsoleWidget - OPTIONAL = [Plugins.MainMenu] - CONF_SECTION = NAME - CONF_FILE = False - TABIFY = [Plugins.IPythonConsole, Plugins.History] - CAN_BE_DISABLED = False - RAISE_AND_FOCUS = True - - # --- Signals - # ------------------------------------------------------------------------ - sig_focus_changed = Signal() # TODO: I think this is not being used now? - - sig_edit_goto_requested = Signal(str, int, str) - """ - This signal will request to open a file in a given row and column - using a code editor. - - Parameters - ---------- - path: str - Path to file. - row: int - Cursor starting row position. - word: str - Word to select on given row. - """ - - sig_refreshed = Signal() - """This signal is emitted when the interpreter buffer is flushed.""" - - sig_help_requested = Signal(dict) - """ - This signal is emitted to request help on a given object `name`. - - Parameters - ---------- - help_data: dict - Example `{'name': str, 'ignore_unknown': bool}`. - """ - - # --- SpyderDockablePlugin API - # ------------------------------------------------------------------------ - @staticmethod - def get_name(): - return _('Internal console') - - def get_icon(self): - return QIcon() - - def get_description(self): - return _('Internal console running Spyder.') - - def on_initialize(self): - widget = self.get_widget() - - # Signals - widget.sig_edit_goto_requested.connect(self.sig_edit_goto_requested) - widget.sig_focus_changed.connect(self.sig_focus_changed) - widget.sig_quit_requested.connect(self.sig_quit_requested) - widget.sig_refreshed.connect(self.sig_refreshed) - widget.sig_help_requested.connect(self.sig_help_requested) - - # Crash handling - previous_crash = self.get_conf( - 'previous_crash', - default='', - section='main', - ) - - if previous_crash: - error_data = dict( - text=previous_crash, - is_traceback=True, - title="Segmentation fault crash", - label=_("

Spyder crashed during last session

"), - steps=_("Please provide any additional information you " - "might have about the crash."), - ) - widget.handle_exception(error_data) - - @on_plugin_available(plugin=Plugins.MainMenu) - def on_main_menu_available(self): - widget = self.get_widget() - mainmenu = self.get_plugin(Plugins.MainMenu) - - # Actions - mainmenu.add_item_to_application_menu( - widget.quit_action, - menu_id=ApplicationMenus.File, - section=FileMenuSections.Restart) - - @on_plugin_teardown(plugin=Plugins.MainMenu) - def on_main_menu_teardown(self): - mainmenu = self.get_plugin(Plugins.MainMenu) - mainmenu.remove_item_from_application_menu( - ConsoleWidgetActions.Quit, - menu_id=ApplicationMenus.File) - - def update_font(self): - font = self.get_font() - self.get_widget().set_font(font) - - def on_close(self, cancelable=False): - self.get_widget().dialog_manager.close_all() - return True - - def on_mainwindow_visible(self): - self.set_exit_function(self.main.closing) - - # Hide this plugin when not in development so that people don't - # use it instead of the IPython console - if DEV is None: - self.toggle_view_action.setChecked(False) - self.dockwidget.hide() - - # --- API - # ------------------------------------------------------------------------ - @Slot() - def report_issue(self): - """Report an issue with the SpyderErrorDialog.""" - self.get_widget().report_issue() - - @property - def error_dialog(self): - """ - Error dialog attribute accesor. - """ - return self.get_widget().error_dlg - - def close_error_dialog(self): - """ - Close the error dialog if visible. - """ - self.get_widget().close_error_dlg() - - def exit_interpreter(self): - """ - Exit the internal console interpreter. - - This is equivalent to requesting the main application to quit. - """ - self.get_widget().exit_interpreter() - - def execute_lines(self, lines): - """ - Execute the given `lines` of code in the internal console. - """ - self.get_widget().execute_lines(lines) - - def get_sys_path(self): - """ - Return the system path of the internal console. - """ - return self.get_widget().get_sys_path() - - @Slot(dict) - def handle_exception(self, error_data, sender=None): - """ - Handle any exception that occurs during Spyder usage. - - Parameters - ---------- - error_data: dict - The dictionary containing error data. The expected keys are: - >>> error_data= { - "text": str, - "is_traceback": bool, - "repo": str, - "title": str, - "label": str, - "steps": str, - } - - Notes - ----- - The `is_traceback` key indicates if `text` contains plain text or a - Python error traceback. - - The `title` and `repo` keys indicate how the error data should - customize the report dialog and Github error submission. - - The `label` and `steps` keys allow customizing the content of the - error dialog. - """ - if sender is None: - sender = self.sender() - self.get_widget().handle_exception( - error_data, - sender=sender - ) - - def quit(self): - """ - Send the quit request to the main application. - """ - self.sig_quit_requested.emit() - - def restore_stds(self): - """ - Restore stdout and stderr when using open file dialogs. - """ - self.get_widget().restore_stds() - - def redirect_stds(self): - """ - Redirect stdout and stderr when using open file dialogs. - """ - self.get_widget().redirect_stds() - - def set_exit_function(self, func): - """ - Set the callback function to execute when the `exit_interpreter` is - called. - """ - self.get_widget().set_exit_function(func) - - def start_interpreter(self, namespace): - """ - Start the internal console interpreter. - - Stdin and stdout are now redirected through the internal console. - """ - widget = self.get_widget() - widget.start_interpreter(namespace) - - def set_namespace_item(self, name, value): - """ - Add an object to the namespace dictionary of the internal console. - """ - self.get_widget().set_namespace_item(name, value) +# -*- coding: utf-8 -*- +# +# Copyright © Spyder Project Contributors +# Licensed under the terms of the MIT License +# (see spyder/__init__.py for details) + +""" +Internal Console Plugin. +""" + +# Standard library imports +import logging + +# Third party imports +from qtpy.QtCore import Signal, Slot +from qtpy.QtGui import QIcon + +# Local imports +from spyder.api.plugins import Plugins, SpyderDockablePlugin +from spyder.api.plugin_registration.decorators import ( + on_plugin_available, on_plugin_teardown) +from spyder.api.translations import get_translation +from spyder.config.base import DEV +from spyder.plugins.console.widgets.main_widget import ( + ConsoleWidget, ConsoleWidgetActions) +from spyder.plugins.mainmenu.api import ApplicationMenus, FileMenuSections + +# Localization +_ = get_translation('spyder') + +# Logging +logger = logging.getLogger(__name__) + + +class Console(SpyderDockablePlugin): + """ + Console widget + """ + NAME = 'internal_console' + WIDGET_CLASS = ConsoleWidget + OPTIONAL = [Plugins.MainMenu] + CONF_SECTION = NAME + CONF_FILE = False + TABIFY = [Plugins.IPythonConsole, Plugins.History] + CAN_BE_DISABLED = False + RAISE_AND_FOCUS = True + + # --- Signals + # ------------------------------------------------------------------------ + sig_focus_changed = Signal() # TODO: I think this is not being used now? + + sig_edit_goto_requested = Signal(str, int, str) + """ + This signal will request to open a file in a given row and column + using a code editor. + + Parameters + ---------- + path: str + Path to file. + row: int + Cursor starting row position. + word: str + Word to select on given row. + """ + + sig_refreshed = Signal() + """This signal is emitted when the interpreter buffer is flushed.""" + + sig_help_requested = Signal(dict) + """ + This signal is emitted to request help on a given object `name`. + + Parameters + ---------- + help_data: dict + Example `{'name': str, 'ignore_unknown': bool}`. + """ + + # --- SpyderDockablePlugin API + # ------------------------------------------------------------------------ + @staticmethod + def get_name(): + return _('Internal console') + + def get_icon(self): + return QIcon() + + def get_description(self): + return _('Internal console running Spyder.') + + def on_initialize(self): + widget = self.get_widget() + + # Signals + widget.sig_edit_goto_requested.connect(self.sig_edit_goto_requested) + widget.sig_focus_changed.connect(self.sig_focus_changed) + widget.sig_quit_requested.connect(self.sig_quit_requested) + widget.sig_refreshed.connect(self.sig_refreshed) + widget.sig_help_requested.connect(self.sig_help_requested) + + # Crash handling + previous_crash = self.get_conf( + 'previous_crash', + default='', + section='main', + ) + + if previous_crash: + error_data = dict( + text=previous_crash, + is_traceback=True, + title="Segmentation fault crash", + label=_("

Spyder crashed during last session

"), + steps=_("Please provide any additional information you " + "might have about the crash."), + ) + widget.handle_exception(error_data) + + @on_plugin_available(plugin=Plugins.MainMenu) + def on_main_menu_available(self): + widget = self.get_widget() + mainmenu = self.get_plugin(Plugins.MainMenu) + + # Actions + mainmenu.add_item_to_application_menu( + widget.quit_action, + menu_id=ApplicationMenus.File, + section=FileMenuSections.Restart) + + @on_plugin_teardown(plugin=Plugins.MainMenu) + def on_main_menu_teardown(self): + mainmenu = self.get_plugin(Plugins.MainMenu) + mainmenu.remove_item_from_application_menu( + ConsoleWidgetActions.Quit, + menu_id=ApplicationMenus.File) + + def update_font(self): + font = self.get_font() + self.get_widget().set_font(font) + + def on_close(self, cancelable=False): + self.get_widget().dialog_manager.close_all() + return True + + def on_mainwindow_visible(self): + self.set_exit_function(self.main.closing) + + # Hide this plugin when not in development so that people don't + # use it instead of the IPython console + if DEV is None: + self.toggle_view_action.setChecked(False) + self.dockwidget.hide() + + # --- API + # ------------------------------------------------------------------------ + @Slot() + def report_issue(self): + """Report an issue with the SpyderErrorDialog.""" + self.get_widget().report_issue() + + @property + def error_dialog(self): + """ + Error dialog attribute accesor. + """ + return self.get_widget().error_dlg + + def close_error_dialog(self): + """ + Close the error dialog if visible. + """ + self.get_widget().close_error_dlg() + + def exit_interpreter(self): + """ + Exit the internal console interpreter. + + This is equivalent to requesting the main application to quit. + """ + self.get_widget().exit_interpreter() + + def execute_lines(self, lines): + """ + Execute the given `lines` of code in the internal console. + """ + self.get_widget().execute_lines(lines) + + def get_sys_path(self): + """ + Return the system path of the internal console. + """ + return self.get_widget().get_sys_path() + + @Slot(dict) + def handle_exception(self, error_data, sender=None): + """ + Handle any exception that occurs during Spyder usage. + + Parameters + ---------- + error_data: dict + The dictionary containing error data. The expected keys are: + >>> error_data= { + "text": str, + "is_traceback": bool, + "repo": str, + "title": str, + "label": str, + "steps": str, + } + + Notes + ----- + The `is_traceback` key indicates if `text` contains plain text or a + Python error traceback. + + The `title` and `repo` keys indicate how the error data should + customize the report dialog and Github error submission. + + The `label` and `steps` keys allow customizing the content of the + error dialog. + """ + if sender is None: + sender = self.sender() + self.get_widget().handle_exception( + error_data, + sender=sender + ) + + def quit(self): + """ + Send the quit request to the main application. + """ + self.sig_quit_requested.emit() + + def restore_stds(self): + """ + Restore stdout and stderr when using open file dialogs. + """ + self.get_widget().restore_stds() + + def redirect_stds(self): + """ + Redirect stdout and stderr when using open file dialogs. + """ + self.get_widget().redirect_stds() + + def set_exit_function(self, func): + """ + Set the callback function to execute when the `exit_interpreter` is + called. + """ + self.get_widget().set_exit_function(func) + + def start_interpreter(self, namespace): + """ + Start the internal console interpreter. + + Stdin and stdout are now redirected through the internal console. + """ + widget = self.get_widget() + widget.start_interpreter(namespace) + + def set_namespace_item(self, name, value): + """ + Add an object to the namespace dictionary of the internal console. + """ + self.get_widget().set_namespace_item(name, value) diff --git a/spyder/plugins/console/utils/ansihandler.py b/spyder/plugins/console/utils/ansihandler.py index f343669f4a8..093ef63b457 100644 --- a/spyder/plugins/console/utils/ansihandler.py +++ b/spyder/plugins/console/utils/ansihandler.py @@ -1,115 +1,115 @@ -# -*- coding: utf-8 -*- -# -# Copyright © Spyder Project Contributors -# Licensed under the terms of the MIT License -# (see spyder/__init__.py for details) - -"""Terminal emulation tools""" - -import os - -class ANSIEscapeCodeHandler(object): - """ANSI Escape sequences handler""" - if os.name == 'nt': - # Windows terminal colors: - ANSI_COLORS = ( # Normal, Bright/Light - ('#000000', '#808080'), # 0: black - ('#800000', '#ff0000'), # 1: red - ('#008000', '#00ff00'), # 2: green - ('#808000', '#ffff00'), # 3: yellow - ('#000080', '#0000ff'), # 4: blue - ('#800080', '#ff00ff'), # 5: magenta - ('#008080', '#00ffff'), # 6: cyan - ('#c0c0c0', '#ffffff'), # 7: white - ) - elif os.name == 'mac': - # Terminal.app colors: - ANSI_COLORS = ( # Normal, Bright/Light - ('#000000', '#818383'), # 0: black - ('#C23621', '#FC391F'), # 1: red - ('#25BC24', '#25BC24'), # 2: green - ('#ADAD27', '#EAEC23'), # 3: yellow - ('#492EE1', '#5833FF'), # 4: blue - ('#D338D3', '#F935F8'), # 5: magenta - ('#33BBC8', '#14F0F0'), # 6: cyan - ('#CBCCCD', '#E9EBEB'), # 7: white - ) - else: - # xterm colors: - ANSI_COLORS = ( # Normal, Bright/Light - ('#000000', '#7F7F7F'), # 0: black - ('#CD0000', '#ff0000'), # 1: red - ('#00CD00', '#00ff00'), # 2: green - ('#CDCD00', '#ffff00'), # 3: yellow - ('#0000EE', '#5C5CFF'), # 4: blue - ('#CD00CD', '#ff00ff'), # 5: magenta - ('#00CDCD', '#00ffff'), # 6: cyan - ('#E5E5E5', '#ffffff'), # 7: white - ) - def __init__(self): - self.intensity = 0 - self.italic = None - self.bold = None - self.underline = None - self.foreground_color = None - self.background_color = None - self.default_foreground_color = 30 - self.default_background_color = 47 - - def set_code(self, code): - assert isinstance(code, int) - if code == 0: - # Reset all settings - self.reset() - elif code == 1: - # Text color intensity - self.intensity = 1 - # The following line is commented because most terminals won't - # change the font weight, against ANSI standard recommendation: -# self.bold = True - elif code == 3: - # Italic on - self.italic = True - elif code == 4: - # Underline simple - self.underline = True - elif code == 22: - # Normal text color intensity - self.intensity = 0 - self.bold = False - elif code == 23: - # No italic - self.italic = False - elif code == 24: - # No underline - self.underline = False - elif code >= 30 and code <= 37: - # Text color - self.foreground_color = code - elif code == 39: - # Default text color - self.foreground_color = self.default_foreground_color - elif code >= 40 and code <= 47: - # Background color - self.background_color = code - elif code == 49: - # Default background color - self.background_color = self.default_background_color - self.set_style() - - def set_style(self): - """ - Set font style with the following attributes: - 'foreground_color', 'background_color', 'italic', - 'bold' and 'underline' - """ - raise NotImplementedError - - def reset(self): - self.current_format = None - self.intensity = 0 - self.italic = False - self.bold = False - self.underline = False - self.foreground_color = None - self.background_color = None +# -*- coding: utf-8 -*- +# +# Copyright © Spyder Project Contributors +# Licensed under the terms of the MIT License +# (see spyder/__init__.py for details) + +"""Terminal emulation tools""" + +import os + +class ANSIEscapeCodeHandler(object): + """ANSI Escape sequences handler""" + if os.name == 'nt': + # Windows terminal colors: + ANSI_COLORS = ( # Normal, Bright/Light + ('#000000', '#808080'), # 0: black + ('#800000', '#ff0000'), # 1: red + ('#008000', '#00ff00'), # 2: green + ('#808000', '#ffff00'), # 3: yellow + ('#000080', '#0000ff'), # 4: blue + ('#800080', '#ff00ff'), # 5: magenta + ('#008080', '#00ffff'), # 6: cyan + ('#c0c0c0', '#ffffff'), # 7: white + ) + elif os.name == 'mac': + # Terminal.app colors: + ANSI_COLORS = ( # Normal, Bright/Light + ('#000000', '#818383'), # 0: black + ('#C23621', '#FC391F'), # 1: red + ('#25BC24', '#25BC24'), # 2: green + ('#ADAD27', '#EAEC23'), # 3: yellow + ('#492EE1', '#5833FF'), # 4: blue + ('#D338D3', '#F935F8'), # 5: magenta + ('#33BBC8', '#14F0F0'), # 6: cyan + ('#CBCCCD', '#E9EBEB'), # 7: white + ) + else: + # xterm colors: + ANSI_COLORS = ( # Normal, Bright/Light + ('#000000', '#7F7F7F'), # 0: black + ('#CD0000', '#ff0000'), # 1: red + ('#00CD00', '#00ff00'), # 2: green + ('#CDCD00', '#ffff00'), # 3: yellow + ('#0000EE', '#5C5CFF'), # 4: blue + ('#CD00CD', '#ff00ff'), # 5: magenta + ('#00CDCD', '#00ffff'), # 6: cyan + ('#E5E5E5', '#ffffff'), # 7: white + ) + def __init__(self): + self.intensity = 0 + self.italic = None + self.bold = None + self.underline = None + self.foreground_color = None + self.background_color = None + self.default_foreground_color = 30 + self.default_background_color = 47 + + def set_code(self, code): + assert isinstance(code, int) + if code == 0: + # Reset all settings + self.reset() + elif code == 1: + # Text color intensity + self.intensity = 1 + # The following line is commented because most terminals won't + # change the font weight, against ANSI standard recommendation: +# self.bold = True + elif code == 3: + # Italic on + self.italic = True + elif code == 4: + # Underline simple + self.underline = True + elif code == 22: + # Normal text color intensity + self.intensity = 0 + self.bold = False + elif code == 23: + # No italic + self.italic = False + elif code == 24: + # No underline + self.underline = False + elif code >= 30 and code <= 37: + # Text color + self.foreground_color = code + elif code == 39: + # Default text color + self.foreground_color = self.default_foreground_color + elif code >= 40 and code <= 47: + # Background color + self.background_color = code + elif code == 49: + # Default background color + self.background_color = self.default_background_color + self.set_style() + + def set_style(self): + """ + Set font style with the following attributes: + 'foreground_color', 'background_color', 'italic', + 'bold' and 'underline' + """ + raise NotImplementedError + + def reset(self): + self.current_format = None + self.intensity = 0 + self.italic = False + self.bold = False + self.underline = False + self.foreground_color = None + self.background_color = None diff --git a/spyder/plugins/console/utils/interpreter.py b/spyder/plugins/console/utils/interpreter.py index 9e063ec93e6..84ed99a34d8 100644 --- a/spyder/plugins/console/utils/interpreter.py +++ b/spyder/plugins/console/utils/interpreter.py @@ -1,335 +1,335 @@ -# -*- coding: utf-8 -*- -# -# Copyright © Spyder Project Contributors -# Licensed under the terms of the MIT License -# (see spyder/__init__.py for details) - -"""Shell Interpreter""" - -from __future__ import print_function - -import sys -import atexit -import threading -import ctypes -import os -import re -import os.path as osp -import pydoc -from code import InteractiveConsole - -from spyder_kernels.utils.dochelpers import isdefined - -# Local imports: -from spyder.utils import encoding, programs -from spyder.py3compat import is_text_string -from spyder.utils.misc import remove_backslashes, getcwd_or_home - -# Force Python to search modules in the current directory first: -sys.path.insert(0, '') - - -def guess_filename(filename): - """Guess filename""" - if osp.isfile(filename): - return filename - if not filename.endswith('.py'): - filename += '.py' - for path in [getcwd_or_home()] + sys.path: - fname = osp.join(path, filename) - if osp.isfile(fname): - return fname - elif osp.isfile(fname+'.py'): - return fname+'.py' - elif osp.isfile(fname+'.pyw'): - return fname+'.pyw' - return filename - -class Interpreter(InteractiveConsole, threading.Thread): - """Interpreter, executed in a separate thread""" - p1 = ">>> " - p2 = "... " - def __init__(self, namespace=None, exitfunc=None, - Output=None, WidgetProxy=None, debug=False): - """ - namespace: locals send to InteractiveConsole object - commands: list of commands executed at startup - """ - InteractiveConsole.__init__(self, namespace) - threading.Thread.__init__(self) - - self._id = None - - self.exit_flag = False - self.debug = debug - - # Execution Status - self.more = False - - if exitfunc is not None: - atexit.register(exitfunc) - - self.namespace = self.locals - self.namespace['__name__'] = '__main__' - self.namespace['execfile'] = self.execfile - self.namespace['runfile'] = self.runfile - self.namespace['raw_input'] = self.raw_input_replacement - self.namespace['help'] = self.help_replacement - - # Capture all interactive input/output - self.initial_stdout = sys.stdout - self.initial_stderr = sys.stderr - self.initial_stdin = sys.stdin - - # Create communication pipes - pr, pw = os.pipe() - self.stdin_read = os.fdopen(pr, "r") - self.stdin_write = os.fdopen(pw, "wb", 0) - self.stdout_write = Output() - self.stderr_write = Output() - - self.input_condition = threading.Condition() - self.widget_proxy = WidgetProxy(self.input_condition) - - self.redirect_stds() - - - #------ Standard input/output - def redirect_stds(self): - """Redirects stds""" - if not self.debug: - sys.stdout = self.stdout_write - sys.stderr = self.stderr_write - sys.stdin = self.stdin_read - - def restore_stds(self): - """Restore stds""" - if not self.debug: - sys.stdout = self.initial_stdout - sys.stderr = self.initial_stderr - sys.stdin = self.initial_stdin - - def raw_input_replacement(self, prompt=''): - """For raw_input builtin function emulation""" - self.widget_proxy.wait_input(prompt) - self.input_condition.acquire() - while not self.widget_proxy.data_available(): - self.input_condition.wait() - inp = self.widget_proxy.input_data - self.input_condition.release() - return inp - - def help_replacement(self, text=None, interactive=False): - """For help builtin function emulation""" - if text is not None and not interactive: - return pydoc.help(text) - elif text is None: - pyver = "%d.%d" % (sys.version_info[0], sys.version_info[1]) - self.write(""" -Welcome to Python %s! This is the online help utility. - -If this is your first time using Python, you should definitely check out -the tutorial on the Internet at https://www.python.org/about/gettingstarted/ - -Enter the name of any module, keyword, or topic to get help on writing -Python programs and using Python modules. To quit this help utility and -return to the interpreter, just type "quit". - -To get a list of available modules, keywords, or topics, type "modules", -"keywords", or "topics". Each module also comes with a one-line summary -of what it does; to list the modules whose summaries contain a given word -such as "spam", type "modules spam". -""" % pyver) - else: - text = text.strip() - try: - eval("pydoc.help(%s)" % text) - except (NameError, SyntaxError): - print("no Python documentation found for '%r'" % text) # spyder: test-skip - self.write(os.linesep) - self.widget_proxy.new_prompt("help> ") - inp = self.raw_input_replacement() - if inp.strip(): - self.help_replacement(inp, interactive=True) - else: - self.write(""" -You are now leaving help and returning to the Python interpreter. -If you want to ask for help on a particular object directly from the -interpreter, you can type "help(object)". Executing "help('string')" -has the same effect as typing a particular string at the help> prompt. -""") - - def run_command(self, cmd, new_prompt=True): - """Run command in interpreter""" - if cmd == 'exit()': - self.exit_flag = True - self.write('\n') - return - # -- Special commands type I - # (transformed into commands executed in the interpreter) - # ? command - special_pattern = r"^%s (?:r\')?(?:u\')?\"?\'?([a-zA-Z0-9_\.]+)" - run_match = re.match(special_pattern % 'run', cmd) - help_match = re.match(r'^([a-zA-Z0-9_\.]+)\?$', cmd) - cd_match = re.match(r"^\!cd \"?\'?([a-zA-Z0-9_ \.]+)", cmd) - if help_match: - cmd = 'help(%s)' % help_match.group(1) - # run command - elif run_match: - filename = guess_filename(run_match.groups()[0]) - cmd = "runfile('%s', args=None)" % remove_backslashes(filename) - # !cd system command - elif cd_match: - cmd = 'import os; os.chdir(r"%s")' % cd_match.groups()[0].strip() - # -- End of Special commands type I - - # -- Special commands type II - # (don't need code execution in interpreter) - xedit_match = re.match(special_pattern % 'xedit', cmd) - edit_match = re.match(special_pattern % 'edit', cmd) - clear_match = re.match(r"^clear ([a-zA-Z0-9_, ]+)", cmd) - # (external) edit command - if xedit_match: - filename = guess_filename(xedit_match.groups()[0]) - self.widget_proxy.edit(filename, external_editor=True) - # local edit command - elif edit_match: - filename = guess_filename(edit_match.groups()[0]) - if osp.isfile(filename): - self.widget_proxy.edit(filename) - else: - self.stderr_write.write( - "No such file or directory: %s\n" % filename) - # remove reference (equivalent to MATLAB's clear command) - elif clear_match: - varnames = clear_match.groups()[0].replace(' ', '').split(',') - for varname in varnames: - try: - self.namespace.pop(varname) - except KeyError: - pass - # Execute command - elif cmd.startswith('!'): - # System ! command - pipe = programs.run_shell_command(cmd[1:]) - txt_out = encoding.transcode( pipe.stdout.read().decode() ) - txt_err = encoding.transcode( pipe.stderr.read().decode().rstrip() ) - if txt_err: - self.stderr_write.write(txt_err) - if txt_out: - self.stdout_write.write(txt_out) - self.stdout_write.write('\n') - self.more = False - # -- End of Special commands type II - else: - # Command executed in the interpreter -# self.widget_proxy.set_readonly(True) - self.more = self.push(cmd) -# self.widget_proxy.set_readonly(False) - - if new_prompt: - self.widget_proxy.new_prompt(self.p2 if self.more else self.p1) - if not self.more: - self.resetbuffer() - - def run(self): - """Wait for input and run it""" - while not self.exit_flag: - self.run_line() - - def run_line(self): - line = self.stdin_read.readline() - if self.exit_flag: - return - # Remove last character which is always '\n': - self.run_command(line[:-1]) - - def get_thread_id(self): - """Return thread id""" - if self._id is None: - for thread_id, obj in list(threading._active.items()): - if obj is self: - self._id = thread_id - return self._id - - def raise_keyboard_interrupt(self): - if self.isAlive(): - ctypes.pythonapi.PyThreadState_SetAsyncExc(self.get_thread_id(), - ctypes.py_object(KeyboardInterrupt)) - return True - else: - return False - - - def closing(self): - """Actions to be done before restarting this interpreter""" - pass - - def execfile(self, filename): - """Exec filename""" - source = open(filename, 'r').read() - try: - try: - name = filename.encode('ascii') - except UnicodeEncodeError: - name = '' - code = compile(source, name, "exec") - except (OverflowError, SyntaxError): - InteractiveConsole.showsyntaxerror(self, filename) - else: - self.runcode(code) - - def runfile(self, filename, args=None): - """ - Run filename - args: command line arguments (string) - """ - if args is not None and not is_text_string(args): - raise TypeError("expected a character buffer object") - self.namespace['__file__'] = filename - sys.argv = [filename] - if args is not None: - for arg in args.split(): - sys.argv.append(arg) - self.execfile(filename) - sys.argv = [''] - self.namespace.pop('__file__') - - def eval(self, text): - """ - Evaluate text and return (obj, valid) - where *obj* is the object represented by *text* - and *valid* is True if object evaluation did not raise any exception - """ - assert is_text_string(text) - try: - return eval(text, self.locals), True - except: - return None, False - - def is_defined(self, objtxt, force_import=False): - """Return True if object is defined""" - return isdefined(objtxt, force_import=force_import, - namespace=self.locals) - - #=========================================================================== - # InteractiveConsole API - #=========================================================================== - def push(self, line): - """ - Push a line of source text to the interpreter - - The line should not have a trailing newline; it may have internal - newlines. The line is appended to a buffer and the interpreter’s - runsource() method is called with the concatenated contents of the - buffer as source. If this indicates that the command was executed - or invalid, the buffer is reset; otherwise, the command is incomplete, - and the buffer is left as it was after the line was appended. - The return value is True if more input is required, False if the line - was dealt with in some way (this is the same as runsource()). - """ - return InteractiveConsole.push(self, "#coding=utf-8\n" + line) - - def resetbuffer(self): - """Remove any unhandled source text from the input buffer""" - InteractiveConsole.resetbuffer(self) +# -*- coding: utf-8 -*- +# +# Copyright © Spyder Project Contributors +# Licensed under the terms of the MIT License +# (see spyder/__init__.py for details) + +"""Shell Interpreter""" + +from __future__ import print_function + +import sys +import atexit +import threading +import ctypes +import os +import re +import os.path as osp +import pydoc +from code import InteractiveConsole + +from spyder_kernels.utils.dochelpers import isdefined + +# Local imports: +from spyder.utils import encoding, programs +from spyder.py3compat import is_text_string +from spyder.utils.misc import remove_backslashes, getcwd_or_home + +# Force Python to search modules in the current directory first: +sys.path.insert(0, '') + + +def guess_filename(filename): + """Guess filename""" + if osp.isfile(filename): + return filename + if not filename.endswith('.py'): + filename += '.py' + for path in [getcwd_or_home()] + sys.path: + fname = osp.join(path, filename) + if osp.isfile(fname): + return fname + elif osp.isfile(fname+'.py'): + return fname+'.py' + elif osp.isfile(fname+'.pyw'): + return fname+'.pyw' + return filename + +class Interpreter(InteractiveConsole, threading.Thread): + """Interpreter, executed in a separate thread""" + p1 = ">>> " + p2 = "... " + def __init__(self, namespace=None, exitfunc=None, + Output=None, WidgetProxy=None, debug=False): + """ + namespace: locals send to InteractiveConsole object + commands: list of commands executed at startup + """ + InteractiveConsole.__init__(self, namespace) + threading.Thread.__init__(self) + + self._id = None + + self.exit_flag = False + self.debug = debug + + # Execution Status + self.more = False + + if exitfunc is not None: + atexit.register(exitfunc) + + self.namespace = self.locals + self.namespace['__name__'] = '__main__' + self.namespace['execfile'] = self.execfile + self.namespace['runfile'] = self.runfile + self.namespace['raw_input'] = self.raw_input_replacement + self.namespace['help'] = self.help_replacement + + # Capture all interactive input/output + self.initial_stdout = sys.stdout + self.initial_stderr = sys.stderr + self.initial_stdin = sys.stdin + + # Create communication pipes + pr, pw = os.pipe() + self.stdin_read = os.fdopen(pr, "r") + self.stdin_write = os.fdopen(pw, "wb", 0) + self.stdout_write = Output() + self.stderr_write = Output() + + self.input_condition = threading.Condition() + self.widget_proxy = WidgetProxy(self.input_condition) + + self.redirect_stds() + + + #------ Standard input/output + def redirect_stds(self): + """Redirects stds""" + if not self.debug: + sys.stdout = self.stdout_write + sys.stderr = self.stderr_write + sys.stdin = self.stdin_read + + def restore_stds(self): + """Restore stds""" + if not self.debug: + sys.stdout = self.initial_stdout + sys.stderr = self.initial_stderr + sys.stdin = self.initial_stdin + + def raw_input_replacement(self, prompt=''): + """For raw_input builtin function emulation""" + self.widget_proxy.wait_input(prompt) + self.input_condition.acquire() + while not self.widget_proxy.data_available(): + self.input_condition.wait() + inp = self.widget_proxy.input_data + self.input_condition.release() + return inp + + def help_replacement(self, text=None, interactive=False): + """For help builtin function emulation""" + if text is not None and not interactive: + return pydoc.help(text) + elif text is None: + pyver = "%d.%d" % (sys.version_info[0], sys.version_info[1]) + self.write(""" +Welcome to Python %s! This is the online help utility. + +If this is your first time using Python, you should definitely check out +the tutorial on the Internet at https://www.python.org/about/gettingstarted/ + +Enter the name of any module, keyword, or topic to get help on writing +Python programs and using Python modules. To quit this help utility and +return to the interpreter, just type "quit". + +To get a list of available modules, keywords, or topics, type "modules", +"keywords", or "topics". Each module also comes with a one-line summary +of what it does; to list the modules whose summaries contain a given word +such as "spam", type "modules spam". +""" % pyver) + else: + text = text.strip() + try: + eval("pydoc.help(%s)" % text) + except (NameError, SyntaxError): + print("no Python documentation found for '%r'" % text) # spyder: test-skip + self.write(os.linesep) + self.widget_proxy.new_prompt("help> ") + inp = self.raw_input_replacement() + if inp.strip(): + self.help_replacement(inp, interactive=True) + else: + self.write(""" +You are now leaving help and returning to the Python interpreter. +If you want to ask for help on a particular object directly from the +interpreter, you can type "help(object)". Executing "help('string')" +has the same effect as typing a particular string at the help> prompt. +""") + + def run_command(self, cmd, new_prompt=True): + """Run command in interpreter""" + if cmd == 'exit()': + self.exit_flag = True + self.write('\n') + return + # -- Special commands type I + # (transformed into commands executed in the interpreter) + # ? command + special_pattern = r"^%s (?:r\')?(?:u\')?\"?\'?([a-zA-Z0-9_\.]+)" + run_match = re.match(special_pattern % 'run', cmd) + help_match = re.match(r'^([a-zA-Z0-9_\.]+)\?$', cmd) + cd_match = re.match(r"^\!cd \"?\'?([a-zA-Z0-9_ \.]+)", cmd) + if help_match: + cmd = 'help(%s)' % help_match.group(1) + # run command + elif run_match: + filename = guess_filename(run_match.groups()[0]) + cmd = "runfile('%s', args=None)" % remove_backslashes(filename) + # !cd system command + elif cd_match: + cmd = 'import os; os.chdir(r"%s")' % cd_match.groups()[0].strip() + # -- End of Special commands type I + + # -- Special commands type II + # (don't need code execution in interpreter) + xedit_match = re.match(special_pattern % 'xedit', cmd) + edit_match = re.match(special_pattern % 'edit', cmd) + clear_match = re.match(r"^clear ([a-zA-Z0-9_, ]+)", cmd) + # (external) edit command + if xedit_match: + filename = guess_filename(xedit_match.groups()[0]) + self.widget_proxy.edit(filename, external_editor=True) + # local edit command + elif edit_match: + filename = guess_filename(edit_match.groups()[0]) + if osp.isfile(filename): + self.widget_proxy.edit(filename) + else: + self.stderr_write.write( + "No such file or directory: %s\n" % filename) + # remove reference (equivalent to MATLAB's clear command) + elif clear_match: + varnames = clear_match.groups()[0].replace(' ', '').split(',') + for varname in varnames: + try: + self.namespace.pop(varname) + except KeyError: + pass + # Execute command + elif cmd.startswith('!'): + # System ! command + pipe = programs.run_shell_command(cmd[1:]) + txt_out = encoding.transcode( pipe.stdout.read().decode() ) + txt_err = encoding.transcode( pipe.stderr.read().decode().rstrip() ) + if txt_err: + self.stderr_write.write(txt_err) + if txt_out: + self.stdout_write.write(txt_out) + self.stdout_write.write('\n') + self.more = False + # -- End of Special commands type II + else: + # Command executed in the interpreter +# self.widget_proxy.set_readonly(True) + self.more = self.push(cmd) +# self.widget_proxy.set_readonly(False) + + if new_prompt: + self.widget_proxy.new_prompt(self.p2 if self.more else self.p1) + if not self.more: + self.resetbuffer() + + def run(self): + """Wait for input and run it""" + while not self.exit_flag: + self.run_line() + + def run_line(self): + line = self.stdin_read.readline() + if self.exit_flag: + return + # Remove last character which is always '\n': + self.run_command(line[:-1]) + + def get_thread_id(self): + """Return thread id""" + if self._id is None: + for thread_id, obj in list(threading._active.items()): + if obj is self: + self._id = thread_id + return self._id + + def raise_keyboard_interrupt(self): + if self.isAlive(): + ctypes.pythonapi.PyThreadState_SetAsyncExc(self.get_thread_id(), + ctypes.py_object(KeyboardInterrupt)) + return True + else: + return False + + + def closing(self): + """Actions to be done before restarting this interpreter""" + pass + + def execfile(self, filename): + """Exec filename""" + source = open(filename, 'r').read() + try: + try: + name = filename.encode('ascii') + except UnicodeEncodeError: + name = '' + code = compile(source, name, "exec") + except (OverflowError, SyntaxError): + InteractiveConsole.showsyntaxerror(self, filename) + else: + self.runcode(code) + + def runfile(self, filename, args=None): + """ + Run filename + args: command line arguments (string) + """ + if args is not None and not is_text_string(args): + raise TypeError("expected a character buffer object") + self.namespace['__file__'] = filename + sys.argv = [filename] + if args is not None: + for arg in args.split(): + sys.argv.append(arg) + self.execfile(filename) + sys.argv = [''] + self.namespace.pop('__file__') + + def eval(self, text): + """ + Evaluate text and return (obj, valid) + where *obj* is the object represented by *text* + and *valid* is True if object evaluation did not raise any exception + """ + assert is_text_string(text) + try: + return eval(text, self.locals), True + except: + return None, False + + def is_defined(self, objtxt, force_import=False): + """Return True if object is defined""" + return isdefined(objtxt, force_import=force_import, + namespace=self.locals) + + #=========================================================================== + # InteractiveConsole API + #=========================================================================== + def push(self, line): + """ + Push a line of source text to the interpreter + + The line should not have a trailing newline; it may have internal + newlines. The line is appended to a buffer and the interpreter’s + runsource() method is called with the concatenated contents of the + buffer as source. If this indicates that the command was executed + or invalid, the buffer is reset; otherwise, the command is incomplete, + and the buffer is left as it was after the line was appended. + The return value is True if more input is required, False if the line + was dealt with in some way (this is the same as runsource()). + """ + return InteractiveConsole.push(self, "#coding=utf-8\n" + line) + + def resetbuffer(self): + """Remove any unhandled source text from the input buffer""" + InteractiveConsole.resetbuffer(self) diff --git a/spyder/plugins/console/widgets/__init__.py b/spyder/plugins/console/widgets/__init__.py index 97000e1ad2c..6d0892f15fe 100644 --- a/spyder/plugins/console/widgets/__init__.py +++ b/spyder/plugins/console/widgets/__init__.py @@ -1,10 +1,10 @@ -# -*- coding: utf-8 -*- -# -# Copyright © Spyder Project Contributors -# Licensed under the terms of the MIT License -# (see spyder/__init__.py for details) - -""" -spyder.plugins.console.widgets -============================== -""" +# -*- coding: utf-8 -*- +# +# Copyright © Spyder Project Contributors +# Licensed under the terms of the MIT License +# (see spyder/__init__.py for details) + +""" +spyder.plugins.console.widgets +============================== +""" diff --git a/spyder/plugins/console/widgets/internalshell.py b/spyder/plugins/console/widgets/internalshell.py index 3b9a73beb20..05919cf9998 100644 --- a/spyder/plugins/console/widgets/internalshell.py +++ b/spyder/plugins/console/widgets/internalshell.py @@ -1,494 +1,494 @@ -# -*- coding: utf-8 -*- -# -# Copyright © Spyder Project Contributors -# Licensed under the terms of the MIT License -# (see spyder/__init__.py for details) - -"""Internal shell widget : PythonShellWidget + Interpreter""" - -# pylint: disable=C0103 -# pylint: disable=R0903 -# pylint: disable=R0911 -# pylint: disable=R0201 - -#FIXME: Internal shell MT: for i in range(100000): print i -> bug - -# Standard library imports -from time import time -import os -import threading - -# Third party imports -from qtpy.QtCore import QEventLoop, QObject, Signal, Slot -from qtpy.QtWidgets import QMessageBox -from spyder_kernels.utils.dochelpers import (getargtxt, getdoc, getobjdir, - getsource) - -# Local imports -from spyder import get_versions -from spyder.api.translations import get_translation -from spyder.plugins.console.utils.interpreter import Interpreter -from spyder.py3compat import (builtins, to_binary_string, - to_text_string) -from spyder.utils.icon_manager import ima -from spyder.utils import programs -from spyder.utils.misc import get_error_match, getcwd_or_home -from spyder.utils.qthelpers import create_action -from spyder.plugins.console.widgets.shell import PythonShellWidget -from spyder.plugins.variableexplorer.widgets.objecteditor import oedit -from spyder.config.base import get_conf_path, get_debug_level - - -# Localization -_ = get_translation('spyder') -builtins.oedit = oedit - - -def create_banner(message): - """Create internal shell banner""" - if message is None: - versions = get_versions() - return 'Python %s %dbits [%s]'\ - % (versions['python'], versions['bitness'], versions['system']) - else: - return message - - -class SysOutput(QObject): - """Handle standard I/O queue""" - data_avail = Signal() - - def __init__(self): - QObject.__init__(self) - self.queue = [] - self.lock = threading.Lock() - - def write(self, val): - self.lock.acquire() - self.queue.append(val) - self.lock.release() - self.data_avail.emit() - - def empty_queue(self): - self.lock.acquire() - s = "".join(self.queue) - self.queue = [] - self.lock.release() - return s - - # We need to add this method to fix spyder-ide/spyder#1789. - def flush(self): - pass - - # This is needed to fix spyder-ide/spyder#2984. - @property - def closed(self): - return False - -class WidgetProxyData(object): - pass - -class WidgetProxy(QObject): - """Handle Shell widget refresh signal""" - - sig_new_prompt = Signal(str) - sig_set_readonly = Signal(bool) - sig_edit = Signal(str, bool) - sig_wait_input = Signal(str) - - def __init__(self, input_condition): - QObject.__init__(self) - - # External editor - self._gotoline = None - self._path = None - self.input_data = None - self.input_condition = input_condition - - def new_prompt(self, prompt): - self.sig_new_prompt.emit(prompt) - - def set_readonly(self, state): - self.sig_set_readonly.emit(state) - - def edit(self, filename, external_editor=False): - self.sig_edit.emit(filename, external_editor) - - def data_available(self): - """Return True if input data is available""" - return self.input_data is not WidgetProxyData - - def wait_input(self, prompt=''): - self.input_data = WidgetProxyData - self.sig_wait_input.emit(prompt) - - def end_input(self, cmd): - self.input_condition.acquire() - self.input_data = cmd - self.input_condition.notify() - self.input_condition.release() - - -class InternalShell(PythonShellWidget): - """Shell base widget: link between PythonShellWidget and Interpreter""" - - # --- Signals - - # This signal is emitted when the buffer is flushed - sig_refreshed = Signal() - - # Request to show a status message on the main window - sig_show_status_requested = Signal(str) - - # This signal emits a parsed error traceback text so we can then - # request opening the file that traceback comes from in the Editor. - sig_go_to_error_requested = Signal(str) - - # TODO: I think this is not being used now? - sig_focus_changed = Signal() - - def __init__(self, parent=None, commands=[], message=None, - max_line_count=300, exitfunc=None, profile=False, - multithreaded=True): - super().__init__(parent, get_conf_path('history_internal.py'), - profile=profile) - - self.multithreaded = multithreaded - self.setMaximumBlockCount(max_line_count) - - # Allow raw_input support: - self.input_loop = None - self.input_mode = False - - # KeyboardInterrupt support - self.interrupted = False # used only for not-multithreaded mode - self.sig_keyboard_interrupt.connect(self.keyboard_interrupt) - - # Code completion / calltips - # keyboard events management - self.eventqueue = [] - - # Init interpreter - self.exitfunc = exitfunc - self.commands = commands - self.message = message - self.interpreter = None - - # Clear status bar - self.sig_show_status_requested.emit('') - - # Embedded shell -- requires the monitor (which installs the - # 'open_in_spyder' function in builtins) - if hasattr(builtins, 'open_in_spyder'): - self.sig_go_to_error_requested.connect( - self.open_with_external_spyder) - - #------ Interpreter - def start_interpreter(self, namespace): - """Start Python interpreter.""" - self.clear() - - if self.interpreter is not None: - self.interpreter.closing() - - self.interpreter = Interpreter(namespace, self.exitfunc, - SysOutput, WidgetProxy, - get_debug_level()) - self.interpreter.stdout_write.data_avail.connect(self.stdout_avail) - self.interpreter.stderr_write.data_avail.connect(self.stderr_avail) - self.interpreter.widget_proxy.sig_set_readonly.connect(self.setReadOnly) - self.interpreter.widget_proxy.sig_new_prompt.connect(self.new_prompt) - self.interpreter.widget_proxy.sig_edit.connect(self.edit_script) - self.interpreter.widget_proxy.sig_wait_input.connect(self.wait_input) - - if self.multithreaded: - self.interpreter.start() - - # Interpreter banner - banner = create_banner(self.message) - self.write(banner, prompt=True) - - # Initial commands - for cmd in self.commands: - self.run_command(cmd, history=False, new_prompt=False) - - # First prompt - self.new_prompt(self.interpreter.p1) - self.sig_refreshed.emit() - - return self.interpreter - - def exit_interpreter(self): - """Exit interpreter""" - self.interpreter.exit_flag = True - if self.multithreaded: - self.interpreter.stdin_write.write(to_binary_string('\n')) - self.interpreter.restore_stds() - - def edit_script(self, filename, external_editor): - filename = to_text_string(filename) - if external_editor: - self.external_editor(filename) - else: - self.parent().edit_script(filename) - - def stdout_avail(self): - """Data is available in stdout, let's empty the queue and write it!""" - data = self.interpreter.stdout_write.empty_queue() - if data: - self.write(data) - - def stderr_avail(self): - """Data is available in stderr, let's empty the queue and write it!""" - data = self.interpreter.stderr_write.empty_queue() - if data: - self.write(data, error=True) - self.flush(error=True) - - - #------Raw input support - def wait_input(self, prompt=''): - """Wait for input (raw_input support)""" - self.new_prompt(prompt) - self.setFocus() - self.input_mode = True - self.input_loop = QEventLoop(None) - self.input_loop.exec_() - self.input_loop = None - - def end_input(self, cmd): - """End of wait_input mode""" - self.input_mode = False - self.input_loop.exit() - self.interpreter.widget_proxy.end_input(cmd) - - - #----- Menus, actions, ... - def setup_context_menu(self): - """Reimplement PythonShellWidget method""" - PythonShellWidget.setup_context_menu(self) - self.help_action = create_action(self, _("Help..."), - icon=ima.icon('DialogHelpButton'), - triggered=self.help) - self.menu.addAction(self.help_action) - - @Slot() - def help(self): - """Help on Spyder console""" - QMessageBox.about(self, _("Help"), - """%s -

%s
edit foobar.py -

%s
xedit foobar.py -

%s
run foobar.py -

%s
clear x, y -

%s
!ls -

%s
object? -

%s
result = oedit(object) - """ % (_('Shell special commands:'), - _('Internal editor:'), - _('External editor:'), - _('Run script:'), - _('Remove references:'), - _('System commands:'), - _('Python help:'), - _('GUI-based editor:'))) - - - #------ External editing - def open_with_external_spyder(self, text): - """Load file in external Spyder's editor, if available - This method is used only for embedded consoles - (could also be useful if we ever implement the magic %edit command)""" - match = get_error_match(to_text_string(text)) - if match: - fname, lnb = match.groups() - builtins.open_in_spyder(fname, int(lnb)) - - def set_external_editor(self, path, gotoline): - """Set external editor path and gotoline option.""" - self._path = path - self._gotoline = gotoline - - def external_editor(self, filename, goto=-1): - """ - Edit in an external editor. - - Recommended: SciTE (e.g. to go to line where an error did occur). - """ - editor_path = self._path - goto_option = self._gotoline - - if os.path.isfile(editor_path): - try: - args = [filename] - if goto > 0 and goto_option: - args.append('%s%d'.format(goto_option, goto)) - - programs.run_program(editor_path, args) - except OSError: - self.write_error("External editor was not found:" - " %s\n" % editor_path) - - #------ I/O - def flush(self, error=False, prompt=False): - """Reimplement ShellBaseWidget method""" - PythonShellWidget.flush(self, error=error, prompt=prompt) - if self.interrupted: - self.interrupted = False - raise KeyboardInterrupt - - - #------ Clear terminal - def clear_terminal(self): - """Reimplement ShellBaseWidget method""" - self.clear() - self.new_prompt(self.interpreter.p2 if self.interpreter.more else self.interpreter.p1) - - - #------ Keyboard events - def on_enter(self, command): - """on_enter""" - if self.profile: - # Simple profiling test - t0 = time() - for _ in range(10): - self.execute_command(command) - self.insert_text(u"\n<Δt>=%dms\n" % (1e2*(time()-t0))) - self.new_prompt(self.interpreter.p1) - else: - self.execute_command(command) - self.__flush_eventqueue() - - def keyPressEvent(self, event): - """ - Reimplement Qt Method - Enhanced keypress event handler - """ - if self.preprocess_keyevent(event): - # Event was accepted in self.preprocess_keyevent - return - self.postprocess_keyevent(event) - - def __flush_eventqueue(self): - """Flush keyboard event queue""" - while self.eventqueue: - past_event = self.eventqueue.pop(0) - self.postprocess_keyevent(past_event) - - #------ Command execution - def keyboard_interrupt(self): - """Simulate keyboard interrupt""" - if self.multithreaded: - self.interpreter.raise_keyboard_interrupt() - else: - if self.interpreter.more: - self.write_error("\nKeyboardInterrupt\n") - self.interpreter.more = False - self.new_prompt(self.interpreter.p1) - self.interpreter.resetbuffer() - else: - self.interrupted = True - - def execute_lines(self, lines): - """ - Execute a set of lines as multiple command - lines: multiple lines of text to be executed as single commands - """ - for line in lines.splitlines(): - stripped_line = line.strip() - if stripped_line.startswith('#'): - continue - self.write(line+os.linesep, flush=True) - self.execute_command(line+"\n") - self.flush() - - def execute_command(self, cmd): - """ - Execute a command - cmd: one-line command only, with '\n' at the end - """ - if self.input_mode: - self.end_input(cmd) - return - if cmd.endswith('\n'): - cmd = cmd[:-1] - # cls command - if cmd == 'cls': - self.clear_terminal() - return - self.run_command(cmd) - - def run_command(self, cmd, history=True, new_prompt=True): - """Run command in interpreter""" - if not cmd: - cmd = '' - else: - if history: - self.add_to_history(cmd) - if not self.multithreaded: - if 'input' not in cmd: - self.interpreter.stdin_write.write( - to_binary_string(cmd + '\n')) - self.interpreter.run_line() - self.sig_refreshed.emit() - else: - self.write(_('In order to use commands like "raw_input" ' - 'or "input" run Spyder with the multithread ' - 'option (--multithread) from a system terminal'), - error=True) - else: - self.interpreter.stdin_write.write(to_binary_string(cmd + '\n')) - - - #------ Code completion / Calltips - def _eval(self, text): - """Is text a valid object?""" - return self.interpreter.eval(text) - - def get_dir(self, objtxt): - """Return dir(object)""" - obj, valid = self._eval(objtxt) - if valid: - return getobjdir(obj) - - def get_globals_keys(self): - """Return shell globals() keys""" - return list(self.interpreter.namespace.keys()) - - def get_cdlistdir(self): - """Return shell current directory list dir""" - return os.listdir(getcwd_or_home()) - - def iscallable(self, objtxt): - """Is object callable?""" - obj, valid = self._eval(objtxt) - if valid: - return callable(obj) - - def get_arglist(self, objtxt): - """Get func/method argument list""" - obj, valid = self._eval(objtxt) - if valid: - return getargtxt(obj) - - def get__doc__(self, objtxt): - """Get object __doc__""" - obj, valid = self._eval(objtxt) - if valid: - return obj.__doc__ - - def get_doc(self, objtxt): - """Get object documentation dictionary""" - obj, valid = self._eval(objtxt) - if valid: - return getdoc(obj) - - def get_source(self, objtxt): - """Get object source""" - obj, valid = self._eval(objtxt) - if valid: - return getsource(obj) - - def is_defined(self, objtxt, force_import=False): - """Return True if object is defined""" - return self.interpreter.is_defined(objtxt, force_import) +# -*- coding: utf-8 -*- +# +# Copyright © Spyder Project Contributors +# Licensed under the terms of the MIT License +# (see spyder/__init__.py for details) + +"""Internal shell widget : PythonShellWidget + Interpreter""" + +# pylint: disable=C0103 +# pylint: disable=R0903 +# pylint: disable=R0911 +# pylint: disable=R0201 + +#FIXME: Internal shell MT: for i in range(100000): print i -> bug + +# Standard library imports +from time import time +import os +import threading + +# Third party imports +from qtpy.QtCore import QEventLoop, QObject, Signal, Slot +from qtpy.QtWidgets import QMessageBox +from spyder_kernels.utils.dochelpers import (getargtxt, getdoc, getobjdir, + getsource) + +# Local imports +from spyder import get_versions +from spyder.api.translations import get_translation +from spyder.plugins.console.utils.interpreter import Interpreter +from spyder.py3compat import (builtins, to_binary_string, + to_text_string) +from spyder.utils.icon_manager import ima +from spyder.utils import programs +from spyder.utils.misc import get_error_match, getcwd_or_home +from spyder.utils.qthelpers import create_action +from spyder.plugins.console.widgets.shell import PythonShellWidget +from spyder.plugins.variableexplorer.widgets.objecteditor import oedit +from spyder.config.base import get_conf_path, get_debug_level + + +# Localization +_ = get_translation('spyder') +builtins.oedit = oedit + + +def create_banner(message): + """Create internal shell banner""" + if message is None: + versions = get_versions() + return 'Python %s %dbits [%s]'\ + % (versions['python'], versions['bitness'], versions['system']) + else: + return message + + +class SysOutput(QObject): + """Handle standard I/O queue""" + data_avail = Signal() + + def __init__(self): + QObject.__init__(self) + self.queue = [] + self.lock = threading.Lock() + + def write(self, val): + self.lock.acquire() + self.queue.append(val) + self.lock.release() + self.data_avail.emit() + + def empty_queue(self): + self.lock.acquire() + s = "".join(self.queue) + self.queue = [] + self.lock.release() + return s + + # We need to add this method to fix spyder-ide/spyder#1789. + def flush(self): + pass + + # This is needed to fix spyder-ide/spyder#2984. + @property + def closed(self): + return False + +class WidgetProxyData(object): + pass + +class WidgetProxy(QObject): + """Handle Shell widget refresh signal""" + + sig_new_prompt = Signal(str) + sig_set_readonly = Signal(bool) + sig_edit = Signal(str, bool) + sig_wait_input = Signal(str) + + def __init__(self, input_condition): + QObject.__init__(self) + + # External editor + self._gotoline = None + self._path = None + self.input_data = None + self.input_condition = input_condition + + def new_prompt(self, prompt): + self.sig_new_prompt.emit(prompt) + + def set_readonly(self, state): + self.sig_set_readonly.emit(state) + + def edit(self, filename, external_editor=False): + self.sig_edit.emit(filename, external_editor) + + def data_available(self): + """Return True if input data is available""" + return self.input_data is not WidgetProxyData + + def wait_input(self, prompt=''): + self.input_data = WidgetProxyData + self.sig_wait_input.emit(prompt) + + def end_input(self, cmd): + self.input_condition.acquire() + self.input_data = cmd + self.input_condition.notify() + self.input_condition.release() + + +class InternalShell(PythonShellWidget): + """Shell base widget: link between PythonShellWidget and Interpreter""" + + # --- Signals + + # This signal is emitted when the buffer is flushed + sig_refreshed = Signal() + + # Request to show a status message on the main window + sig_show_status_requested = Signal(str) + + # This signal emits a parsed error traceback text so we can then + # request opening the file that traceback comes from in the Editor. + sig_go_to_error_requested = Signal(str) + + # TODO: I think this is not being used now? + sig_focus_changed = Signal() + + def __init__(self, parent=None, commands=[], message=None, + max_line_count=300, exitfunc=None, profile=False, + multithreaded=True): + super().__init__(parent, get_conf_path('history_internal.py'), + profile=profile) + + self.multithreaded = multithreaded + self.setMaximumBlockCount(max_line_count) + + # Allow raw_input support: + self.input_loop = None + self.input_mode = False + + # KeyboardInterrupt support + self.interrupted = False # used only for not-multithreaded mode + self.sig_keyboard_interrupt.connect(self.keyboard_interrupt) + + # Code completion / calltips + # keyboard events management + self.eventqueue = [] + + # Init interpreter + self.exitfunc = exitfunc + self.commands = commands + self.message = message + self.interpreter = None + + # Clear status bar + self.sig_show_status_requested.emit('') + + # Embedded shell -- requires the monitor (which installs the + # 'open_in_spyder' function in builtins) + if hasattr(builtins, 'open_in_spyder'): + self.sig_go_to_error_requested.connect( + self.open_with_external_spyder) + + #------ Interpreter + def start_interpreter(self, namespace): + """Start Python interpreter.""" + self.clear() + + if self.interpreter is not None: + self.interpreter.closing() + + self.interpreter = Interpreter(namespace, self.exitfunc, + SysOutput, WidgetProxy, + get_debug_level()) + self.interpreter.stdout_write.data_avail.connect(self.stdout_avail) + self.interpreter.stderr_write.data_avail.connect(self.stderr_avail) + self.interpreter.widget_proxy.sig_set_readonly.connect(self.setReadOnly) + self.interpreter.widget_proxy.sig_new_prompt.connect(self.new_prompt) + self.interpreter.widget_proxy.sig_edit.connect(self.edit_script) + self.interpreter.widget_proxy.sig_wait_input.connect(self.wait_input) + + if self.multithreaded: + self.interpreter.start() + + # Interpreter banner + banner = create_banner(self.message) + self.write(banner, prompt=True) + + # Initial commands + for cmd in self.commands: + self.run_command(cmd, history=False, new_prompt=False) + + # First prompt + self.new_prompt(self.interpreter.p1) + self.sig_refreshed.emit() + + return self.interpreter + + def exit_interpreter(self): + """Exit interpreter""" + self.interpreter.exit_flag = True + if self.multithreaded: + self.interpreter.stdin_write.write(to_binary_string('\n')) + self.interpreter.restore_stds() + + def edit_script(self, filename, external_editor): + filename = to_text_string(filename) + if external_editor: + self.external_editor(filename) + else: + self.parent().edit_script(filename) + + def stdout_avail(self): + """Data is available in stdout, let's empty the queue and write it!""" + data = self.interpreter.stdout_write.empty_queue() + if data: + self.write(data) + + def stderr_avail(self): + """Data is available in stderr, let's empty the queue and write it!""" + data = self.interpreter.stderr_write.empty_queue() + if data: + self.write(data, error=True) + self.flush(error=True) + + + #------Raw input support + def wait_input(self, prompt=''): + """Wait for input (raw_input support)""" + self.new_prompt(prompt) + self.setFocus() + self.input_mode = True + self.input_loop = QEventLoop(None) + self.input_loop.exec_() + self.input_loop = None + + def end_input(self, cmd): + """End of wait_input mode""" + self.input_mode = False + self.input_loop.exit() + self.interpreter.widget_proxy.end_input(cmd) + + + #----- Menus, actions, ... + def setup_context_menu(self): + """Reimplement PythonShellWidget method""" + PythonShellWidget.setup_context_menu(self) + self.help_action = create_action(self, _("Help..."), + icon=ima.icon('DialogHelpButton'), + triggered=self.help) + self.menu.addAction(self.help_action) + + @Slot() + def help(self): + """Help on Spyder console""" + QMessageBox.about(self, _("Help"), + """%s +

%s
edit foobar.py +

%s
xedit foobar.py +

%s
run foobar.py +

%s
clear x, y +

%s
!ls +

%s
object? +

%s
result = oedit(object) + """ % (_('Shell special commands:'), + _('Internal editor:'), + _('External editor:'), + _('Run script:'), + _('Remove references:'), + _('System commands:'), + _('Python help:'), + _('GUI-based editor:'))) + + + #------ External editing + def open_with_external_spyder(self, text): + """Load file in external Spyder's editor, if available + This method is used only for embedded consoles + (could also be useful if we ever implement the magic %edit command)""" + match = get_error_match(to_text_string(text)) + if match: + fname, lnb = match.groups() + builtins.open_in_spyder(fname, int(lnb)) + + def set_external_editor(self, path, gotoline): + """Set external editor path and gotoline option.""" + self._path = path + self._gotoline = gotoline + + def external_editor(self, filename, goto=-1): + """ + Edit in an external editor. + + Recommended: SciTE (e.g. to go to line where an error did occur). + """ + editor_path = self._path + goto_option = self._gotoline + + if os.path.isfile(editor_path): + try: + args = [filename] + if goto > 0 and goto_option: + args.append('%s%d'.format(goto_option, goto)) + + programs.run_program(editor_path, args) + except OSError: + self.write_error("External editor was not found:" + " %s\n" % editor_path) + + #------ I/O + def flush(self, error=False, prompt=False): + """Reimplement ShellBaseWidget method""" + PythonShellWidget.flush(self, error=error, prompt=prompt) + if self.interrupted: + self.interrupted = False + raise KeyboardInterrupt + + + #------ Clear terminal + def clear_terminal(self): + """Reimplement ShellBaseWidget method""" + self.clear() + self.new_prompt(self.interpreter.p2 if self.interpreter.more else self.interpreter.p1) + + + #------ Keyboard events + def on_enter(self, command): + """on_enter""" + if self.profile: + # Simple profiling test + t0 = time() + for _ in range(10): + self.execute_command(command) + self.insert_text(u"\n<Δt>=%dms\n" % (1e2*(time()-t0))) + self.new_prompt(self.interpreter.p1) + else: + self.execute_command(command) + self.__flush_eventqueue() + + def keyPressEvent(self, event): + """ + Reimplement Qt Method + Enhanced keypress event handler + """ + if self.preprocess_keyevent(event): + # Event was accepted in self.preprocess_keyevent + return + self.postprocess_keyevent(event) + + def __flush_eventqueue(self): + """Flush keyboard event queue""" + while self.eventqueue: + past_event = self.eventqueue.pop(0) + self.postprocess_keyevent(past_event) + + #------ Command execution + def keyboard_interrupt(self): + """Simulate keyboard interrupt""" + if self.multithreaded: + self.interpreter.raise_keyboard_interrupt() + else: + if self.interpreter.more: + self.write_error("\nKeyboardInterrupt\n") + self.interpreter.more = False + self.new_prompt(self.interpreter.p1) + self.interpreter.resetbuffer() + else: + self.interrupted = True + + def execute_lines(self, lines): + """ + Execute a set of lines as multiple command + lines: multiple lines of text to be executed as single commands + """ + for line in lines.splitlines(): + stripped_line = line.strip() + if stripped_line.startswith('#'): + continue + self.write(line+os.linesep, flush=True) + self.execute_command(line+"\n") + self.flush() + + def execute_command(self, cmd): + """ + Execute a command + cmd: one-line command only, with '\n' at the end + """ + if self.input_mode: + self.end_input(cmd) + return + if cmd.endswith('\n'): + cmd = cmd[:-1] + # cls command + if cmd == 'cls': + self.clear_terminal() + return + self.run_command(cmd) + + def run_command(self, cmd, history=True, new_prompt=True): + """Run command in interpreter""" + if not cmd: + cmd = '' + else: + if history: + self.add_to_history(cmd) + if not self.multithreaded: + if 'input' not in cmd: + self.interpreter.stdin_write.write( + to_binary_string(cmd + '\n')) + self.interpreter.run_line() + self.sig_refreshed.emit() + else: + self.write(_('In order to use commands like "raw_input" ' + 'or "input" run Spyder with the multithread ' + 'option (--multithread) from a system terminal'), + error=True) + else: + self.interpreter.stdin_write.write(to_binary_string(cmd + '\n')) + + + #------ Code completion / Calltips + def _eval(self, text): + """Is text a valid object?""" + return self.interpreter.eval(text) + + def get_dir(self, objtxt): + """Return dir(object)""" + obj, valid = self._eval(objtxt) + if valid: + return getobjdir(obj) + + def get_globals_keys(self): + """Return shell globals() keys""" + return list(self.interpreter.namespace.keys()) + + def get_cdlistdir(self): + """Return shell current directory list dir""" + return os.listdir(getcwd_or_home()) + + def iscallable(self, objtxt): + """Is object callable?""" + obj, valid = self._eval(objtxt) + if valid: + return callable(obj) + + def get_arglist(self, objtxt): + """Get func/method argument list""" + obj, valid = self._eval(objtxt) + if valid: + return getargtxt(obj) + + def get__doc__(self, objtxt): + """Get object __doc__""" + obj, valid = self._eval(objtxt) + if valid: + return obj.__doc__ + + def get_doc(self, objtxt): + """Get object documentation dictionary""" + obj, valid = self._eval(objtxt) + if valid: + return getdoc(obj) + + def get_source(self, objtxt): + """Get object source""" + obj, valid = self._eval(objtxt) + if valid: + return getsource(obj) + + def is_defined(self, objtxt, force_import=False): + """Return True if object is defined""" + return self.interpreter.is_defined(objtxt, force_import) diff --git a/spyder/plugins/console/widgets/shell.py b/spyder/plugins/console/widgets/shell.py index 7f307023a66..5458957f68e 100644 --- a/spyder/plugins/console/widgets/shell.py +++ b/spyder/plugins/console/widgets/shell.py @@ -1,1064 +1,1064 @@ -# -*- coding: utf-8 -*- -# -# Copyright © Spyder Project Contributors -# Licensed under the terms of the MIT License -# (see spyder/__init__.py for details) - -"""Shell widgets: base, python and terminal""" - -# pylint: disable=C0103 -# pylint: disable=R0903 -# pylint: disable=R0911 -# pylint: disable=R0201 - -# Standard library imports -import keyword -import locale -import os -import os.path as osp -import re -import sys -import time - -# Third party imports -from qtpy.compat import getsavefilename -from qtpy.QtCore import Property, QCoreApplication, Qt, QTimer, Signal, Slot -from qtpy.QtGui import QKeySequence, QTextCharFormat, QTextCursor -from qtpy.QtWidgets import QApplication, QMenu, QToolTip - -# Local import -from spyder.config.base import _, get_conf_path, get_debug_level, STDERR -from spyder.config.manager import CONF -from spyder.py3compat import (builtins, is_string, is_text_string, - PY3, str_lower, to_text_string) -from spyder.utils import encoding -from spyder.utils.icon_manager import ima -from spyder.utils.qthelpers import (add_actions, create_action, keybinding, - restore_keyevent) -from spyder.widgets.mixins import (GetHelpMixin, SaveHistoryMixin, - TracebackLinksMixin, BrowseHistoryMixin) -from spyder.plugins.console.widgets.console import ConsoleBaseWidget - - -# Maximum number of lines to load -MAX_LINES = 1000 - - -class ShellBaseWidget(ConsoleBaseWidget, SaveHistoryMixin, - BrowseHistoryMixin): - """ - Shell base widget - """ - - sig_redirect_stdio_requested = Signal(bool) - sig_keyboard_interrupt = Signal() - execute = Signal(str) - sig_append_to_history_requested = Signal(str, str) - - def __init__(self, parent, history_filename, profile=False, - initial_message=None, default_foreground_color=None, - error_foreground_color=None, traceback_foreground_color=None, - prompt_foreground_color=None, background_color=None): - """ - parent : specifies the parent widget - """ - ConsoleBaseWidget.__init__(self, parent) - SaveHistoryMixin.__init__(self, history_filename) - BrowseHistoryMixin.__init__(self) - - # Prompt position: tuple (line, index) - self.current_prompt_pos = None - self.new_input_line = True - - # History - assert is_text_string(history_filename) - self.history = self.load_history() - - # Session - self.historylog_filename = CONF.get('main', 'historylog_filename', - get_conf_path('history.log')) - - # Context menu - self.menu = None - self.setup_context_menu() - - # Simple profiling test - self.profile = profile - - # Buffer to increase performance of write/flush operations - self.__buffer = [] - if initial_message: - self.__buffer.append(initial_message) - - self.__timestamp = 0.0 - self.__flushtimer = QTimer(self) - self.__flushtimer.setSingleShot(True) - self.__flushtimer.timeout.connect(self.flush) - - # Give focus to widget - self.setFocus() - - # Cursor width - self.setCursorWidth(CONF.get('main', 'cursor/width')) - - # Adjustments to completion_widget to use it here - self.completion_widget.currentRowChanged.disconnect() - - def toggle_wrap_mode(self, enable): - """Enable/disable wrap mode""" - self.set_wrap_mode('character' if enable else None) - - def set_font(self, font): - """Set shell styles font""" - self.setFont(font) - self.set_pythonshell_font(font) - cursor = self.textCursor() - cursor.select(QTextCursor.Document) - charformat = QTextCharFormat() - charformat.setFontFamily(font.family()) - charformat.setFontPointSize(font.pointSize()) - cursor.mergeCharFormat(charformat) - - - #------ Context menu - def setup_context_menu(self): - """Setup shell context menu""" - self.menu = QMenu(self) - self.cut_action = create_action(self, _("Cut"), - shortcut=keybinding('Cut'), - icon=ima.icon('editcut'), - triggered=self.cut) - self.copy_action = create_action(self, _("Copy"), - shortcut=keybinding('Copy'), - icon=ima.icon('editcopy'), - triggered=self.copy) - paste_action = create_action(self, _("Paste"), - shortcut=keybinding('Paste'), - icon=ima.icon('editpaste'), - triggered=self.paste) - save_action = create_action(self, _("Save history log..."), - icon=ima.icon('filesave'), - tip=_("Save current history log (i.e. all " - "inputs and outputs) in a text file"), - triggered=self.save_historylog) - self.delete_action = create_action(self, _("Delete"), - shortcut=keybinding('Delete'), - icon=ima.icon('editdelete'), - triggered=self.delete) - selectall_action = create_action(self, _("Select All"), - shortcut=keybinding('SelectAll'), - icon=ima.icon('selectall'), - triggered=self.selectAll) - add_actions(self.menu, (self.cut_action, self.copy_action, - paste_action, self.delete_action, None, - selectall_action, None, save_action) ) - - def contextMenuEvent(self, event): - """Reimplement Qt method""" - state = self.has_selected_text() - self.copy_action.setEnabled(state) - self.cut_action.setEnabled(state) - self.delete_action.setEnabled(state) - self.menu.popup(event.globalPos()) - event.accept() - - - #------ Input buffer - def get_current_line_from_cursor(self): - return self.get_text('cursor', 'eof') - - def _select_input(self): - """Select current line (without selecting console prompt)""" - line, index = self.get_position('eof') - if self.current_prompt_pos is None: - pline, pindex = line, index - else: - pline, pindex = self.current_prompt_pos - self.setSelection(pline, pindex, line, index) - - @Slot() - def clear_terminal(self): - """ - Clear terminal window - Child classes reimplement this method to write prompt - """ - self.clear() - - # The buffer being edited - def _set_input_buffer(self, text): - """Set input buffer""" - if self.current_prompt_pos is not None: - self.replace_text(self.current_prompt_pos, 'eol', text) - else: - self.insert(text) - self.set_cursor_position('eof') - - def _get_input_buffer(self): - """Return input buffer""" - input_buffer = '' - if self.current_prompt_pos is not None: - input_buffer = self.get_text(self.current_prompt_pos, 'eol') - input_buffer = input_buffer.replace(os.linesep, '\n') - return input_buffer - - input_buffer = Property("QString", _get_input_buffer, _set_input_buffer) - - - #------ Prompt - def new_prompt(self, prompt): - """ - Print a new prompt and save its (line, index) position - """ - if self.get_cursor_line_column()[1] != 0: - self.write('\n') - self.write(prompt, prompt=True) - # now we update our cursor giving end of prompt - self.current_prompt_pos = self.get_position('cursor') - self.ensureCursorVisible() - self.new_input_line = False - - def check_selection(self): - """ - Check if selected text is r/w, - otherwise remove read-only parts of selection - """ - if self.current_prompt_pos is None: - self.set_cursor_position('eof') - else: - self.truncate_selection(self.current_prompt_pos) - - - #------ Copy / Keyboard interrupt - @Slot() - def copy(self): - """Copy text to clipboard... or keyboard interrupt""" - if self.has_selected_text(): - ConsoleBaseWidget.copy(self) - elif not sys.platform == 'darwin': - self.interrupt() - - def interrupt(self): - """Keyboard interrupt""" - self.sig_keyboard_interrupt.emit() - - @Slot() - def cut(self): - """Cut text""" - self.check_selection() - if self.has_selected_text(): - ConsoleBaseWidget.cut(self) - - @Slot() - def delete(self): - """Remove selected text""" - self.check_selection() - if self.has_selected_text(): - ConsoleBaseWidget.remove_selected_text(self) - - @Slot() - def save_historylog(self): - """Save current history log (all text in console)""" - title = _("Save history log") - self.sig_redirect_stdio_requested.emit(False) - filename, _selfilter = getsavefilename(self, title, - self.historylog_filename, "%s (*.log)" % _("History logs")) - self.sig_redirect_stdio_requested.emit(True) - if filename: - filename = osp.normpath(filename) - try: - encoding.write(to_text_string(self.get_text_with_eol()), - filename) - self.historylog_filename = filename - CONF.set('main', 'historylog_filename', filename) - except EnvironmentError: - pass - - #------ Basic keypress event handler - def on_enter(self, command): - """on_enter""" - self.execute_command(command) - - def execute_command(self, command): - self.execute.emit(command) - self.add_to_history(command) - self.new_input_line = True - - def on_new_line(self): - """On new input line""" - self.set_cursor_position('eof') - self.current_prompt_pos = self.get_position('cursor') - self.new_input_line = False - - @Slot() - def paste(self): - """Reimplemented slot to handle multiline paste action""" - if self.new_input_line: - self.on_new_line() - ConsoleBaseWidget.paste(self) - - def keyPressEvent(self, event): - """ - Reimplement Qt Method - Basic keypress event handler - (reimplemented in InternalShell to add more sophisticated features) - """ - if self.preprocess_keyevent(event): - # Event was accepted in self.preprocess_keyevent - return - self.postprocess_keyevent(event) - - def preprocess_keyevent(self, event): - """Pre-process keypress event: - return True if event is accepted, false otherwise""" - # Copy must be done first to be able to copy read-only text parts - # (otherwise, right below, we would remove selection - # if not on current line) - ctrl = event.modifiers() & Qt.ControlModifier - meta = event.modifiers() & Qt.MetaModifier # meta=ctrl in OSX - if event.key() == Qt.Key_C and \ - ((Qt.MetaModifier | Qt.ControlModifier) & event.modifiers()): - if meta and sys.platform == 'darwin': - self.interrupt() - elif ctrl: - self.copy() - event.accept() - return True - - if self.new_input_line and ( len(event.text()) or event.key() in \ - (Qt.Key_Up, Qt.Key_Down, Qt.Key_Left, Qt.Key_Right) ): - self.on_new_line() - - return False - - def postprocess_keyevent(self, event): - """Post-process keypress event: - in InternalShell, this is method is called when shell is ready""" - event, text, key, ctrl, shift = restore_keyevent(event) - - # Is cursor on the last line? and after prompt? - if len(text): - #XXX: Shouldn't it be: `if len(unicode(text).strip(os.linesep))` ? - if self.has_selected_text(): - self.check_selection() - self.restrict_cursor_position(self.current_prompt_pos, 'eof') - - cursor_position = self.get_position('cursor') - - if key in (Qt.Key_Return, Qt.Key_Enter): - if self.is_cursor_on_last_line(): - self._key_enter() - # add and run selection - else: - self.insert_text(self.get_selected_text(), at_end=True) - - elif key == Qt.Key_Insert and not shift and not ctrl: - self.setOverwriteMode(not self.overwriteMode()) - - elif key == Qt.Key_Delete: - if self.has_selected_text(): - self.check_selection() - self.remove_selected_text() - elif self.is_cursor_on_last_line(): - self.stdkey_clear() - - elif key == Qt.Key_Backspace: - self._key_backspace(cursor_position) - - elif key == Qt.Key_Tab: - self._key_tab() - - elif key == Qt.Key_Space and ctrl: - self._key_ctrl_space() - - elif key == Qt.Key_Left: - if self.current_prompt_pos == cursor_position: - # Avoid moving cursor on prompt - return - method = self.extend_selection_to_next if shift \ - else self.move_cursor_to_next - method('word' if ctrl else 'character', direction='left') - - elif key == Qt.Key_Right: - if self.is_cursor_at_end(): - return - method = self.extend_selection_to_next if shift \ - else self.move_cursor_to_next - method('word' if ctrl else 'character', direction='right') - - elif (key == Qt.Key_Home) or ((key == Qt.Key_Up) and ctrl): - self._key_home(shift, ctrl) - - elif (key == Qt.Key_End) or ((key == Qt.Key_Down) and ctrl): - self._key_end(shift, ctrl) - - elif key == Qt.Key_Up: - if not self.is_cursor_on_last_line(): - self.set_cursor_position('eof') - y_cursor = self.get_coordinates(cursor_position)[1] - y_prompt = self.get_coordinates(self.current_prompt_pos)[1] - if y_cursor > y_prompt: - self.stdkey_up(shift) - else: - self.browse_history(backward=True) - - elif key == Qt.Key_Down: - if not self.is_cursor_on_last_line(): - self.set_cursor_position('eof') - y_cursor = self.get_coordinates(cursor_position)[1] - y_end = self.get_coordinates('eol')[1] - if y_cursor < y_end: - self.stdkey_down(shift) - else: - self.browse_history(backward=False) - - elif key in (Qt.Key_PageUp, Qt.Key_PageDown): - #XXX: Find a way to do this programmatically instead of calling - # widget keyhandler (this won't work if the *event* is coming from - # the event queue - i.e. if the busy buffer is ever implemented) - ConsoleBaseWidget.keyPressEvent(self, event) - - elif key == Qt.Key_Escape and shift: - self.clear_line() - - elif key == Qt.Key_Escape: - self._key_escape() - - elif key == Qt.Key_L and ctrl: - self.clear_terminal() - - elif key == Qt.Key_V and ctrl: - self.paste() - - elif key == Qt.Key_X and ctrl: - self.cut() - - elif key == Qt.Key_Z and ctrl: - self.undo() - - elif key == Qt.Key_Y and ctrl: - self.redo() - - elif key == Qt.Key_A and ctrl: - self.selectAll() - - elif key == Qt.Key_Question and not self.has_selected_text(): - self._key_question(text) - - elif key == Qt.Key_ParenLeft and not self.has_selected_text(): - self._key_parenleft(text) - - elif key == Qt.Key_Period and not self.has_selected_text(): - self._key_period(text) - - elif len(text) and not self.isReadOnly(): - self.hist_wholeline = False - self.insert_text(text) - self._key_other(text) - - else: - # Let the parent widget handle the key press event - ConsoleBaseWidget.keyPressEvent(self, event) - - - #------ Key handlers - def _key_enter(self): - command = self.input_buffer - self.insert_text('\n', at_end=True) - self.on_enter(command) - self.flush() - def _key_other(self, text): - raise NotImplementedError - def _key_backspace(self, cursor_position): - raise NotImplementedError - def _key_tab(self): - raise NotImplementedError - def _key_ctrl_space(self): - raise NotImplementedError - def _key_home(self, shift, ctrl): - if self.is_cursor_on_last_line(): - self.stdkey_home(shift, ctrl, self.current_prompt_pos) - def _key_end(self, shift, ctrl): - if self.is_cursor_on_last_line(): - self.stdkey_end(shift, ctrl) - def _key_pageup(self): - raise NotImplementedError - def _key_pagedown(self): - raise NotImplementedError - def _key_escape(self): - raise NotImplementedError - def _key_question(self, text): - raise NotImplementedError - def _key_parenleft(self, text): - raise NotImplementedError - def _key_period(self, text): - raise NotImplementedError - - - #------ History Management - def load_history(self): - """Load history from a .py file in user home directory""" - if osp.isfile(self.history_filename): - rawhistory, _ = encoding.readlines(self.history_filename) - rawhistory = [line.replace('\n', '') for line in rawhistory] - if rawhistory[1] != self.INITHISTORY[1]: - rawhistory[1] = self.INITHISTORY[1] - else: - rawhistory = self.INITHISTORY - history = [line for line in rawhistory \ - if line and not line.startswith('#')] - - # Truncating history to X entries: - while len(history) >= MAX_LINES: - del history[0] - while rawhistory[0].startswith('#'): - del rawhistory[0] - del rawhistory[0] - - # Saving truncated history: - try: - encoding.writelines(rawhistory, self.history_filename) - except EnvironmentError: - pass - - return history - - #------ Simulation standards input/output - def write_error(self, text): - """Simulate stderr""" - self.flush() - self.write(text, flush=True, error=True) - if get_debug_level(): - STDERR.write(text) - - def write(self, text, flush=False, error=False, prompt=False): - """Simulate stdout and stderr""" - if prompt: - self.flush() - if not is_string(text): - # This test is useful to discriminate QStrings from decoded str - text = to_text_string(text) - self.__buffer.append(text) - ts = time.time() - if flush or prompt: - self.flush(error=error, prompt=prompt) - elif ts - self.__timestamp > 0.05: - self.flush(error=error) - self.__timestamp = ts - # Timer to flush strings cached by last write() operation in series - self.__flushtimer.start(50) - - def flush(self, error=False, prompt=False): - """Flush buffer, write text to console""" - # Fix for spyder-ide/spyder#2452 - if PY3: - try: - text = "".join(self.__buffer) - except TypeError: - text = b"".join(self.__buffer) - try: - text = text.decode( locale.getdefaultlocale()[1] ) - except: - pass - else: - text = "".join(self.__buffer) - - self.__buffer = [] - self.insert_text(text, at_end=True, error=error, prompt=prompt) - - # The lines below are causing a hard crash when Qt generates - # internal warnings. We replaced them instead for self.update(), - # which prevents the crash. - # See spyder-ide/spyder#10893 - # QCoreApplication.processEvents() - # self.repaint() - self.update() - - # Clear input buffer: - self.new_input_line = True - - - #------ Text Insertion - def insert_text(self, text, at_end=False, error=False, prompt=False): - """ - Insert text at the current cursor position - or at the end of the command line - """ - if at_end: - # Insert text at the end of the command line - self.append_text_to_shell(text, error, prompt) - else: - # Insert text at current cursor position - ConsoleBaseWidget.insert_text(self, text) - - - #------ Re-implemented Qt Methods - def focusNextPrevChild(self, next): - """ - Reimplemented to stop Tab moving to the next window - """ - if next: - return False - return ConsoleBaseWidget.focusNextPrevChild(self, next) - - - #------ Drag and drop - def dragEnterEvent(self, event): - """Drag and Drop - Enter event""" - event.setAccepted(event.mimeData().hasFormat("text/plain")) - - def dragMoveEvent(self, event): - """Drag and Drop - Move event""" - if (event.mimeData().hasFormat("text/plain")): - event.setDropAction(Qt.MoveAction) - event.accept() - else: - event.ignore() - - def dropEvent(self, event): - """Drag and Drop - Drop event""" - if (event.mimeData().hasFormat("text/plain")): - text = to_text_string(event.mimeData().text()) - if self.new_input_line: - self.on_new_line() - self.insert_text(text, at_end=True) - self.setFocus() - event.setDropAction(Qt.MoveAction) - event.accept() - else: - event.ignore() - - def drop_pathlist(self, pathlist): - """Drop path list""" - raise NotImplementedError - - -# Example how to debug complex interclass call chains: -# -# from spyder.utils.debug import log_methods_calls -# log_methods_calls('log.log', ShellBaseWidget) - -class PythonShellWidget(TracebackLinksMixin, ShellBaseWidget, - GetHelpMixin): - """Python shell widget""" - QT_CLASS = ShellBaseWidget - INITHISTORY = ['# -*- coding: utf-8 -*-', - '# *** Spyder Python Console History Log ***',] - SEPARATOR = '%s##---(%s)---' % (os.linesep*2, time.ctime()) - - # --- Signals - # This signal emits a parsed error traceback text so we can then - # request opening the file that traceback comes from in the Editor. - sig_go_to_error_requested = Signal(str) - - # Signal - sig_help_requested = Signal(dict) - """ - This signal is emitted to request help on a given object `name`. - - Parameters - ---------- - help_data: dict - Example `{'name': str, 'ignore_unknown': bool}`. - """ - - def __init__(self, parent, history_filename, profile=False, initial_message=None): - ShellBaseWidget.__init__(self, parent, history_filename, - profile=profile, - initial_message=initial_message) - TracebackLinksMixin.__init__(self) - GetHelpMixin.__init__(self) - - # Local shortcuts - self.shortcuts = self.create_shortcuts() - - def create_shortcuts(self): - array_inline = CONF.config_shortcut( - lambda: self.enter_array_inline(), - context='array_builder', - name='enter array inline', - parent=self) - array_table = CONF.config_shortcut( - lambda: self.enter_array_table(), - context='array_builder', - name='enter array table', - parent=self) - inspectsc = CONF.config_shortcut( - self.inspect_current_object, - context='Console', - name='Inspect current object', - parent=self) - clear_line_sc = CONF.config_shortcut( - self.clear_line, - context='Console', - name="Clear line", - parent=self, - ) - clear_shell_sc = CONF.config_shortcut( - self.clear_terminal, - context='Console', - name="Clear shell", - parent=self, - ) - - return [inspectsc, array_inline, array_table, clear_line_sc, - clear_shell_sc] - - def get_shortcut_data(self): - """ - Returns shortcut data, a list of tuples (shortcut, text, default) - shortcut (QShortcut or QAction instance) - text (string): action/shortcut description - default (string): default key sequence - """ - return [sc.data for sc in self.shortcuts] - - #------ Context menu - def setup_context_menu(self): - """Reimplements ShellBaseWidget method""" - ShellBaseWidget.setup_context_menu(self) - self.copy_without_prompts_action = create_action( - self, - _("Copy without prompts"), - icon=ima.icon('copywop'), - triggered=self.copy_without_prompts) - - clear_line_action = create_action( - self, - _("Clear line"), - QKeySequence(CONF.get_shortcut('console', 'Clear line')), - icon=ima.icon('editdelete'), - tip=_("Clear line"), - triggered=self.clear_line) - - clear_action = create_action( - self, - _("Clear shell"), - QKeySequence(CONF.get_shortcut('console', 'Clear shell')), - icon=ima.icon('editclear'), - tip=_("Clear shell contents ('cls' command)"), - triggered=self.clear_terminal) - - add_actions(self.menu, (self.copy_without_prompts_action, - clear_line_action, clear_action)) - - def contextMenuEvent(self, event): - """Reimplements ShellBaseWidget method""" - state = self.has_selected_text() - self.copy_without_prompts_action.setEnabled(state) - ShellBaseWidget.contextMenuEvent(self, event) - - @Slot() - def copy_without_prompts(self): - """Copy text to clipboard without prompts""" - text = self.get_selected_text() - lines = text.split(os.linesep) - for index, line in enumerate(lines): - if line.startswith('>>> ') or line.startswith('... '): - lines[index] = line[4:] - text = os.linesep.join(lines) - QApplication.clipboard().setText(text) - - - #------ Key handlers - def postprocess_keyevent(self, event): - """Process keypress event""" - ShellBaseWidget.postprocess_keyevent(self, event) - - def _key_other(self, text): - """1 character key""" - if self.is_completion_widget_visible(): - self.completion_text += text - - def _key_backspace(self, cursor_position): - """Action for Backspace key""" - if self.has_selected_text(): - self.check_selection() - self.remove_selected_text() - elif self.current_prompt_pos == cursor_position: - # Avoid deleting prompt - return - elif self.is_cursor_on_last_line(): - self.stdkey_backspace() - if self.is_completion_widget_visible(): - # Removing only last character because if there was a selection - # the completion widget would have been canceled - self.completion_text = self.completion_text[:-1] - - def _key_tab(self): - """Action for TAB key""" - if self.is_cursor_on_last_line(): - empty_line = not self.get_current_line_to_cursor().strip() - if empty_line: - self.stdkey_tab() - else: - self.show_code_completion() - - def _key_ctrl_space(self): - """Action for Ctrl+Space""" - if not self.is_completion_widget_visible(): - self.show_code_completion() - - def _key_pageup(self): - """Action for PageUp key""" - pass - - def _key_pagedown(self): - """Action for PageDown key""" - pass - - def _key_escape(self): - """Action for ESCAPE key""" - if self.is_completion_widget_visible(): - self.hide_completion_widget() - - def _key_question(self, text): - """Action for '?'""" - if self.get_current_line_to_cursor(): - last_obj = self.get_last_obj() - if last_obj and not last_obj.isdigit(): - self.show_object_info(last_obj) - self.insert_text(text) - # In case calltip and completion are shown at the same time: - if self.is_completion_widget_visible(): - self.completion_text += '?' - - def _key_parenleft(self, text): - """Action for '('""" - self.hide_completion_widget() - if self.get_current_line_to_cursor(): - last_obj = self.get_last_obj() - if last_obj and not last_obj.isdigit(): - self.insert_text(text) - self.show_object_info(last_obj, call=True) - return - self.insert_text(text) - - def _key_period(self, text): - """Action for '.'""" - self.insert_text(text) - if self.codecompletion_auto: - # Enable auto-completion only if last token isn't a float - last_obj = self.get_last_obj() - if last_obj and not last_obj.isdigit(): - self.show_code_completion() - - - #------ Paste - def paste(self): - """Reimplemented slot to handle multiline paste action""" - text = to_text_string(QApplication.clipboard().text()) - if len(text.splitlines()) > 1: - # Multiline paste - if self.new_input_line: - self.on_new_line() - self.remove_selected_text() # Remove selection, eventually - end = self.get_current_line_from_cursor() - lines = self.get_current_line_to_cursor() + text + end - self.clear_line() - self.execute_lines(lines) - self.move_cursor(-len(end)) - else: - # Standard paste - ShellBaseWidget.paste(self) - - # ------ Code Completion / Calltips - # Methods implemented in child class: - # (e.g. InternalShell) - def get_dir(self, objtxt): - """Return dir(object)""" - raise NotImplementedError - - def get_globals_keys(self): - """Return shell globals() keys""" - raise NotImplementedError - - def get_cdlistdir(self): - """Return shell current directory list dir""" - raise NotImplementedError - - def iscallable(self, objtxt): - """Is object callable?""" - raise NotImplementedError - - def get_arglist(self, objtxt): - """Get func/method argument list""" - raise NotImplementedError - - def get__doc__(self, objtxt): - """Get object __doc__""" - raise NotImplementedError - - def get_doc(self, objtxt): - """Get object documentation dictionary""" - raise NotImplementedError - - def get_source(self, objtxt): - """Get object source""" - raise NotImplementedError - - def is_defined(self, objtxt, force_import=False): - """Return True if object is defined""" - raise NotImplementedError - - def show_completion_widget(self, textlist): - """Show completion widget""" - self.completion_widget.show_list( - textlist, automatic=False, position=None) - - def hide_completion_widget(self, focus_to_parent=True): - """Hide completion widget""" - self.completion_widget.hide(focus_to_parent=focus_to_parent) - - def show_completion_list(self, completions, completion_text=""): - """Display the possible completions""" - if not completions: - return - if not isinstance(completions[0], tuple): - completions = [(c, '') for c in completions] - if len(completions) == 1 and completions[0][0] == completion_text: - return - self.completion_text = completion_text - # Sorting completion list (entries starting with underscore are - # put at the end of the list): - underscore = set([(comp, t) for (comp, t) in completions - if comp.startswith('_')]) - - completions = sorted(set(completions) - underscore, - key=lambda x: str_lower(x[0])) - completions += sorted(underscore, key=lambda x: str_lower(x[0])) - self.show_completion_widget(completions) - - def show_code_completion(self): - """Display a completion list based on the current line""" - # Note: unicode conversion is needed only for ExternalShellBase - text = to_text_string(self.get_current_line_to_cursor()) - last_obj = self.get_last_obj() - if not text: - return - - obj_dir = self.get_dir(last_obj) - if last_obj and obj_dir and text.endswith('.'): - self.show_completion_list(obj_dir) - return - - # Builtins and globals - if not text.endswith('.') and last_obj \ - and re.match(r'[a-zA-Z_0-9]*$', last_obj): - b_k_g = dir(builtins)+self.get_globals_keys()+keyword.kwlist - for objname in b_k_g: - if objname.startswith(last_obj) and objname != last_obj: - self.show_completion_list(b_k_g, completion_text=last_obj) - return - else: - return - - # Looking for an incomplete completion - if last_obj is None: - last_obj = text - dot_pos = last_obj.rfind('.') - if dot_pos != -1: - if dot_pos == len(last_obj)-1: - completion_text = "" - else: - completion_text = last_obj[dot_pos+1:] - last_obj = last_obj[:dot_pos] - completions = self.get_dir(last_obj) - if completions is not None: - self.show_completion_list(completions, - completion_text=completion_text) - return - - # Looking for ' or ": filename completion - q_pos = max([text.rfind("'"), text.rfind('"')]) - if q_pos != -1: - completions = self.get_cdlistdir() - if completions: - self.show_completion_list(completions, - completion_text=text[q_pos+1:]) - return - - #------ Drag'n Drop - def drop_pathlist(self, pathlist): - """Drop path list""" - if pathlist: - files = ["r'%s'" % path for path in pathlist] - if len(files) == 1: - text = files[0] - else: - text = "[" + ", ".join(files) + "]" - if self.new_input_line: - self.on_new_line() - self.insert_text(text) - self.setFocus() - - -class TerminalWidget(ShellBaseWidget): - """ - Terminal widget - """ - COM = 'rem' if os.name == 'nt' else '#' - INITHISTORY = ['%s *** Spyder Terminal History Log ***' % COM, COM,] - SEPARATOR = '%s%s ---(%s)---' % (os.linesep*2, COM, time.ctime()) - - # This signal emits a parsed error traceback text so we can then - # request opening the file that traceback comes from in the Editor. - sig_go_to_error_requested = Signal(str) - - def __init__(self, parent, history_filename, profile=False): - ShellBaseWidget.__init__(self, parent, history_filename, profile) - - #------ Key handlers - def _key_other(self, text): - """1 character key""" - pass - - def _key_backspace(self, cursor_position): - """Action for Backspace key""" - if self.has_selected_text(): - self.check_selection() - self.remove_selected_text() - elif self.current_prompt_pos == cursor_position: - # Avoid deleting prompt - return - elif self.is_cursor_on_last_line(): - self.stdkey_backspace() - - def _key_tab(self): - """Action for TAB key""" - if self.is_cursor_on_last_line(): - self.stdkey_tab() - - def _key_ctrl_space(self): - """Action for Ctrl+Space""" - pass - - def _key_escape(self): - """Action for ESCAPE key""" - self.clear_line() - - def _key_question(self, text): - """Action for '?'""" - self.insert_text(text) - - def _key_parenleft(self, text): - """Action for '('""" - self.insert_text(text) - - def _key_period(self, text): - """Action for '.'""" - self.insert_text(text) - - - #------ Drag'n Drop - def drop_pathlist(self, pathlist): - """Drop path list""" - if pathlist: - files = ['"%s"' % path for path in pathlist] - if len(files) == 1: - text = files[0] - else: - text = " ".join(files) - if self.new_input_line: - self.on_new_line() - self.insert_text(text) - self.setFocus() +# -*- coding: utf-8 -*- +# +# Copyright © Spyder Project Contributors +# Licensed under the terms of the MIT License +# (see spyder/__init__.py for details) + +"""Shell widgets: base, python and terminal""" + +# pylint: disable=C0103 +# pylint: disable=R0903 +# pylint: disable=R0911 +# pylint: disable=R0201 + +# Standard library imports +import keyword +import locale +import os +import os.path as osp +import re +import sys +import time + +# Third party imports +from qtpy.compat import getsavefilename +from qtpy.QtCore import Property, QCoreApplication, Qt, QTimer, Signal, Slot +from qtpy.QtGui import QKeySequence, QTextCharFormat, QTextCursor +from qtpy.QtWidgets import QApplication, QMenu, QToolTip + +# Local import +from spyder.config.base import _, get_conf_path, get_debug_level, STDERR +from spyder.config.manager import CONF +from spyder.py3compat import (builtins, is_string, is_text_string, + PY3, str_lower, to_text_string) +from spyder.utils import encoding +from spyder.utils.icon_manager import ima +from spyder.utils.qthelpers import (add_actions, create_action, keybinding, + restore_keyevent) +from spyder.widgets.mixins import (GetHelpMixin, SaveHistoryMixin, + TracebackLinksMixin, BrowseHistoryMixin) +from spyder.plugins.console.widgets.console import ConsoleBaseWidget + + +# Maximum number of lines to load +MAX_LINES = 1000 + + +class ShellBaseWidget(ConsoleBaseWidget, SaveHistoryMixin, + BrowseHistoryMixin): + """ + Shell base widget + """ + + sig_redirect_stdio_requested = Signal(bool) + sig_keyboard_interrupt = Signal() + execute = Signal(str) + sig_append_to_history_requested = Signal(str, str) + + def __init__(self, parent, history_filename, profile=False, + initial_message=None, default_foreground_color=None, + error_foreground_color=None, traceback_foreground_color=None, + prompt_foreground_color=None, background_color=None): + """ + parent : specifies the parent widget + """ + ConsoleBaseWidget.__init__(self, parent) + SaveHistoryMixin.__init__(self, history_filename) + BrowseHistoryMixin.__init__(self) + + # Prompt position: tuple (line, index) + self.current_prompt_pos = None + self.new_input_line = True + + # History + assert is_text_string(history_filename) + self.history = self.load_history() + + # Session + self.historylog_filename = CONF.get('main', 'historylog_filename', + get_conf_path('history.log')) + + # Context menu + self.menu = None + self.setup_context_menu() + + # Simple profiling test + self.profile = profile + + # Buffer to increase performance of write/flush operations + self.__buffer = [] + if initial_message: + self.__buffer.append(initial_message) + + self.__timestamp = 0.0 + self.__flushtimer = QTimer(self) + self.__flushtimer.setSingleShot(True) + self.__flushtimer.timeout.connect(self.flush) + + # Give focus to widget + self.setFocus() + + # Cursor width + self.setCursorWidth(CONF.get('main', 'cursor/width')) + + # Adjustments to completion_widget to use it here + self.completion_widget.currentRowChanged.disconnect() + + def toggle_wrap_mode(self, enable): + """Enable/disable wrap mode""" + self.set_wrap_mode('character' if enable else None) + + def set_font(self, font): + """Set shell styles font""" + self.setFont(font) + self.set_pythonshell_font(font) + cursor = self.textCursor() + cursor.select(QTextCursor.Document) + charformat = QTextCharFormat() + charformat.setFontFamily(font.family()) + charformat.setFontPointSize(font.pointSize()) + cursor.mergeCharFormat(charformat) + + + #------ Context menu + def setup_context_menu(self): + """Setup shell context menu""" + self.menu = QMenu(self) + self.cut_action = create_action(self, _("Cut"), + shortcut=keybinding('Cut'), + icon=ima.icon('editcut'), + triggered=self.cut) + self.copy_action = create_action(self, _("Copy"), + shortcut=keybinding('Copy'), + icon=ima.icon('editcopy'), + triggered=self.copy) + paste_action = create_action(self, _("Paste"), + shortcut=keybinding('Paste'), + icon=ima.icon('editpaste'), + triggered=self.paste) + save_action = create_action(self, _("Save history log..."), + icon=ima.icon('filesave'), + tip=_("Save current history log (i.e. all " + "inputs and outputs) in a text file"), + triggered=self.save_historylog) + self.delete_action = create_action(self, _("Delete"), + shortcut=keybinding('Delete'), + icon=ima.icon('editdelete'), + triggered=self.delete) + selectall_action = create_action(self, _("Select All"), + shortcut=keybinding('SelectAll'), + icon=ima.icon('selectall'), + triggered=self.selectAll) + add_actions(self.menu, (self.cut_action, self.copy_action, + paste_action, self.delete_action, None, + selectall_action, None, save_action) ) + + def contextMenuEvent(self, event): + """Reimplement Qt method""" + state = self.has_selected_text() + self.copy_action.setEnabled(state) + self.cut_action.setEnabled(state) + self.delete_action.setEnabled(state) + self.menu.popup(event.globalPos()) + event.accept() + + + #------ Input buffer + def get_current_line_from_cursor(self): + return self.get_text('cursor', 'eof') + + def _select_input(self): + """Select current line (without selecting console prompt)""" + line, index = self.get_position('eof') + if self.current_prompt_pos is None: + pline, pindex = line, index + else: + pline, pindex = self.current_prompt_pos + self.setSelection(pline, pindex, line, index) + + @Slot() + def clear_terminal(self): + """ + Clear terminal window + Child classes reimplement this method to write prompt + """ + self.clear() + + # The buffer being edited + def _set_input_buffer(self, text): + """Set input buffer""" + if self.current_prompt_pos is not None: + self.replace_text(self.current_prompt_pos, 'eol', text) + else: + self.insert(text) + self.set_cursor_position('eof') + + def _get_input_buffer(self): + """Return input buffer""" + input_buffer = '' + if self.current_prompt_pos is not None: + input_buffer = self.get_text(self.current_prompt_pos, 'eol') + input_buffer = input_buffer.replace(os.linesep, '\n') + return input_buffer + + input_buffer = Property("QString", _get_input_buffer, _set_input_buffer) + + + #------ Prompt + def new_prompt(self, prompt): + """ + Print a new prompt and save its (line, index) position + """ + if self.get_cursor_line_column()[1] != 0: + self.write('\n') + self.write(prompt, prompt=True) + # now we update our cursor giving end of prompt + self.current_prompt_pos = self.get_position('cursor') + self.ensureCursorVisible() + self.new_input_line = False + + def check_selection(self): + """ + Check if selected text is r/w, + otherwise remove read-only parts of selection + """ + if self.current_prompt_pos is None: + self.set_cursor_position('eof') + else: + self.truncate_selection(self.current_prompt_pos) + + + #------ Copy / Keyboard interrupt + @Slot() + def copy(self): + """Copy text to clipboard... or keyboard interrupt""" + if self.has_selected_text(): + ConsoleBaseWidget.copy(self) + elif not sys.platform == 'darwin': + self.interrupt() + + def interrupt(self): + """Keyboard interrupt""" + self.sig_keyboard_interrupt.emit() + + @Slot() + def cut(self): + """Cut text""" + self.check_selection() + if self.has_selected_text(): + ConsoleBaseWidget.cut(self) + + @Slot() + def delete(self): + """Remove selected text""" + self.check_selection() + if self.has_selected_text(): + ConsoleBaseWidget.remove_selected_text(self) + + @Slot() + def save_historylog(self): + """Save current history log (all text in console)""" + title = _("Save history log") + self.sig_redirect_stdio_requested.emit(False) + filename, _selfilter = getsavefilename(self, title, + self.historylog_filename, "%s (*.log)" % _("History logs")) + self.sig_redirect_stdio_requested.emit(True) + if filename: + filename = osp.normpath(filename) + try: + encoding.write(to_text_string(self.get_text_with_eol()), + filename) + self.historylog_filename = filename + CONF.set('main', 'historylog_filename', filename) + except EnvironmentError: + pass + + #------ Basic keypress event handler + def on_enter(self, command): + """on_enter""" + self.execute_command(command) + + def execute_command(self, command): + self.execute.emit(command) + self.add_to_history(command) + self.new_input_line = True + + def on_new_line(self): + """On new input line""" + self.set_cursor_position('eof') + self.current_prompt_pos = self.get_position('cursor') + self.new_input_line = False + + @Slot() + def paste(self): + """Reimplemented slot to handle multiline paste action""" + if self.new_input_line: + self.on_new_line() + ConsoleBaseWidget.paste(self) + + def keyPressEvent(self, event): + """ + Reimplement Qt Method + Basic keypress event handler + (reimplemented in InternalShell to add more sophisticated features) + """ + if self.preprocess_keyevent(event): + # Event was accepted in self.preprocess_keyevent + return + self.postprocess_keyevent(event) + + def preprocess_keyevent(self, event): + """Pre-process keypress event: + return True if event is accepted, false otherwise""" + # Copy must be done first to be able to copy read-only text parts + # (otherwise, right below, we would remove selection + # if not on current line) + ctrl = event.modifiers() & Qt.ControlModifier + meta = event.modifiers() & Qt.MetaModifier # meta=ctrl in OSX + if event.key() == Qt.Key_C and \ + ((Qt.MetaModifier | Qt.ControlModifier) & event.modifiers()): + if meta and sys.platform == 'darwin': + self.interrupt() + elif ctrl: + self.copy() + event.accept() + return True + + if self.new_input_line and ( len(event.text()) or event.key() in \ + (Qt.Key_Up, Qt.Key_Down, Qt.Key_Left, Qt.Key_Right) ): + self.on_new_line() + + return False + + def postprocess_keyevent(self, event): + """Post-process keypress event: + in InternalShell, this is method is called when shell is ready""" + event, text, key, ctrl, shift = restore_keyevent(event) + + # Is cursor on the last line? and after prompt? + if len(text): + #XXX: Shouldn't it be: `if len(unicode(text).strip(os.linesep))` ? + if self.has_selected_text(): + self.check_selection() + self.restrict_cursor_position(self.current_prompt_pos, 'eof') + + cursor_position = self.get_position('cursor') + + if key in (Qt.Key_Return, Qt.Key_Enter): + if self.is_cursor_on_last_line(): + self._key_enter() + # add and run selection + else: + self.insert_text(self.get_selected_text(), at_end=True) + + elif key == Qt.Key_Insert and not shift and not ctrl: + self.setOverwriteMode(not self.overwriteMode()) + + elif key == Qt.Key_Delete: + if self.has_selected_text(): + self.check_selection() + self.remove_selected_text() + elif self.is_cursor_on_last_line(): + self.stdkey_clear() + + elif key == Qt.Key_Backspace: + self._key_backspace(cursor_position) + + elif key == Qt.Key_Tab: + self._key_tab() + + elif key == Qt.Key_Space and ctrl: + self._key_ctrl_space() + + elif key == Qt.Key_Left: + if self.current_prompt_pos == cursor_position: + # Avoid moving cursor on prompt + return + method = self.extend_selection_to_next if shift \ + else self.move_cursor_to_next + method('word' if ctrl else 'character', direction='left') + + elif key == Qt.Key_Right: + if self.is_cursor_at_end(): + return + method = self.extend_selection_to_next if shift \ + else self.move_cursor_to_next + method('word' if ctrl else 'character', direction='right') + + elif (key == Qt.Key_Home) or ((key == Qt.Key_Up) and ctrl): + self._key_home(shift, ctrl) + + elif (key == Qt.Key_End) or ((key == Qt.Key_Down) and ctrl): + self._key_end(shift, ctrl) + + elif key == Qt.Key_Up: + if not self.is_cursor_on_last_line(): + self.set_cursor_position('eof') + y_cursor = self.get_coordinates(cursor_position)[1] + y_prompt = self.get_coordinates(self.current_prompt_pos)[1] + if y_cursor > y_prompt: + self.stdkey_up(shift) + else: + self.browse_history(backward=True) + + elif key == Qt.Key_Down: + if not self.is_cursor_on_last_line(): + self.set_cursor_position('eof') + y_cursor = self.get_coordinates(cursor_position)[1] + y_end = self.get_coordinates('eol')[1] + if y_cursor < y_end: + self.stdkey_down(shift) + else: + self.browse_history(backward=False) + + elif key in (Qt.Key_PageUp, Qt.Key_PageDown): + #XXX: Find a way to do this programmatically instead of calling + # widget keyhandler (this won't work if the *event* is coming from + # the event queue - i.e. if the busy buffer is ever implemented) + ConsoleBaseWidget.keyPressEvent(self, event) + + elif key == Qt.Key_Escape and shift: + self.clear_line() + + elif key == Qt.Key_Escape: + self._key_escape() + + elif key == Qt.Key_L and ctrl: + self.clear_terminal() + + elif key == Qt.Key_V and ctrl: + self.paste() + + elif key == Qt.Key_X and ctrl: + self.cut() + + elif key == Qt.Key_Z and ctrl: + self.undo() + + elif key == Qt.Key_Y and ctrl: + self.redo() + + elif key == Qt.Key_A and ctrl: + self.selectAll() + + elif key == Qt.Key_Question and not self.has_selected_text(): + self._key_question(text) + + elif key == Qt.Key_ParenLeft and not self.has_selected_text(): + self._key_parenleft(text) + + elif key == Qt.Key_Period and not self.has_selected_text(): + self._key_period(text) + + elif len(text) and not self.isReadOnly(): + self.hist_wholeline = False + self.insert_text(text) + self._key_other(text) + + else: + # Let the parent widget handle the key press event + ConsoleBaseWidget.keyPressEvent(self, event) + + + #------ Key handlers + def _key_enter(self): + command = self.input_buffer + self.insert_text('\n', at_end=True) + self.on_enter(command) + self.flush() + def _key_other(self, text): + raise NotImplementedError + def _key_backspace(self, cursor_position): + raise NotImplementedError + def _key_tab(self): + raise NotImplementedError + def _key_ctrl_space(self): + raise NotImplementedError + def _key_home(self, shift, ctrl): + if self.is_cursor_on_last_line(): + self.stdkey_home(shift, ctrl, self.current_prompt_pos) + def _key_end(self, shift, ctrl): + if self.is_cursor_on_last_line(): + self.stdkey_end(shift, ctrl) + def _key_pageup(self): + raise NotImplementedError + def _key_pagedown(self): + raise NotImplementedError + def _key_escape(self): + raise NotImplementedError + def _key_question(self, text): + raise NotImplementedError + def _key_parenleft(self, text): + raise NotImplementedError + def _key_period(self, text): + raise NotImplementedError + + + #------ History Management + def load_history(self): + """Load history from a .py file in user home directory""" + if osp.isfile(self.history_filename): + rawhistory, _ = encoding.readlines(self.history_filename) + rawhistory = [line.replace('\n', '') for line in rawhistory] + if rawhistory[1] != self.INITHISTORY[1]: + rawhistory[1] = self.INITHISTORY[1] + else: + rawhistory = self.INITHISTORY + history = [line for line in rawhistory \ + if line and not line.startswith('#')] + + # Truncating history to X entries: + while len(history) >= MAX_LINES: + del history[0] + while rawhistory[0].startswith('#'): + del rawhistory[0] + del rawhistory[0] + + # Saving truncated history: + try: + encoding.writelines(rawhistory, self.history_filename) + except EnvironmentError: + pass + + return history + + #------ Simulation standards input/output + def write_error(self, text): + """Simulate stderr""" + self.flush() + self.write(text, flush=True, error=True) + if get_debug_level(): + STDERR.write(text) + + def write(self, text, flush=False, error=False, prompt=False): + """Simulate stdout and stderr""" + if prompt: + self.flush() + if not is_string(text): + # This test is useful to discriminate QStrings from decoded str + text = to_text_string(text) + self.__buffer.append(text) + ts = time.time() + if flush or prompt: + self.flush(error=error, prompt=prompt) + elif ts - self.__timestamp > 0.05: + self.flush(error=error) + self.__timestamp = ts + # Timer to flush strings cached by last write() operation in series + self.__flushtimer.start(50) + + def flush(self, error=False, prompt=False): + """Flush buffer, write text to console""" + # Fix for spyder-ide/spyder#2452 + if PY3: + try: + text = "".join(self.__buffer) + except TypeError: + text = b"".join(self.__buffer) + try: + text = text.decode( locale.getdefaultlocale()[1] ) + except: + pass + else: + text = "".join(self.__buffer) + + self.__buffer = [] + self.insert_text(text, at_end=True, error=error, prompt=prompt) + + # The lines below are causing a hard crash when Qt generates + # internal warnings. We replaced them instead for self.update(), + # which prevents the crash. + # See spyder-ide/spyder#10893 + # QCoreApplication.processEvents() + # self.repaint() + self.update() + + # Clear input buffer: + self.new_input_line = True + + + #------ Text Insertion + def insert_text(self, text, at_end=False, error=False, prompt=False): + """ + Insert text at the current cursor position + or at the end of the command line + """ + if at_end: + # Insert text at the end of the command line + self.append_text_to_shell(text, error, prompt) + else: + # Insert text at current cursor position + ConsoleBaseWidget.insert_text(self, text) + + + #------ Re-implemented Qt Methods + def focusNextPrevChild(self, next): + """ + Reimplemented to stop Tab moving to the next window + """ + if next: + return False + return ConsoleBaseWidget.focusNextPrevChild(self, next) + + + #------ Drag and drop + def dragEnterEvent(self, event): + """Drag and Drop - Enter event""" + event.setAccepted(event.mimeData().hasFormat("text/plain")) + + def dragMoveEvent(self, event): + """Drag and Drop - Move event""" + if (event.mimeData().hasFormat("text/plain")): + event.setDropAction(Qt.MoveAction) + event.accept() + else: + event.ignore() + + def dropEvent(self, event): + """Drag and Drop - Drop event""" + if (event.mimeData().hasFormat("text/plain")): + text = to_text_string(event.mimeData().text()) + if self.new_input_line: + self.on_new_line() + self.insert_text(text, at_end=True) + self.setFocus() + event.setDropAction(Qt.MoveAction) + event.accept() + else: + event.ignore() + + def drop_pathlist(self, pathlist): + """Drop path list""" + raise NotImplementedError + + +# Example how to debug complex interclass call chains: +# +# from spyder.utils.debug import log_methods_calls +# log_methods_calls('log.log', ShellBaseWidget) + +class PythonShellWidget(TracebackLinksMixin, ShellBaseWidget, + GetHelpMixin): + """Python shell widget""" + QT_CLASS = ShellBaseWidget + INITHISTORY = ['# -*- coding: utf-8 -*-', + '# *** Spyder Python Console History Log ***',] + SEPARATOR = '%s##---(%s)---' % (os.linesep*2, time.ctime()) + + # --- Signals + # This signal emits a parsed error traceback text so we can then + # request opening the file that traceback comes from in the Editor. + sig_go_to_error_requested = Signal(str) + + # Signal + sig_help_requested = Signal(dict) + """ + This signal is emitted to request help on a given object `name`. + + Parameters + ---------- + help_data: dict + Example `{'name': str, 'ignore_unknown': bool}`. + """ + + def __init__(self, parent, history_filename, profile=False, initial_message=None): + ShellBaseWidget.__init__(self, parent, history_filename, + profile=profile, + initial_message=initial_message) + TracebackLinksMixin.__init__(self) + GetHelpMixin.__init__(self) + + # Local shortcuts + self.shortcuts = self.create_shortcuts() + + def create_shortcuts(self): + array_inline = CONF.config_shortcut( + lambda: self.enter_array_inline(), + context='array_builder', + name='enter array inline', + parent=self) + array_table = CONF.config_shortcut( + lambda: self.enter_array_table(), + context='array_builder', + name='enter array table', + parent=self) + inspectsc = CONF.config_shortcut( + self.inspect_current_object, + context='Console', + name='Inspect current object', + parent=self) + clear_line_sc = CONF.config_shortcut( + self.clear_line, + context='Console', + name="Clear line", + parent=self, + ) + clear_shell_sc = CONF.config_shortcut( + self.clear_terminal, + context='Console', + name="Clear shell", + parent=self, + ) + + return [inspectsc, array_inline, array_table, clear_line_sc, + clear_shell_sc] + + def get_shortcut_data(self): + """ + Returns shortcut data, a list of tuples (shortcut, text, default) + shortcut (QShortcut or QAction instance) + text (string): action/shortcut description + default (string): default key sequence + """ + return [sc.data for sc in self.shortcuts] + + #------ Context menu + def setup_context_menu(self): + """Reimplements ShellBaseWidget method""" + ShellBaseWidget.setup_context_menu(self) + self.copy_without_prompts_action = create_action( + self, + _("Copy without prompts"), + icon=ima.icon('copywop'), + triggered=self.copy_without_prompts) + + clear_line_action = create_action( + self, + _("Clear line"), + QKeySequence(CONF.get_shortcut('console', 'Clear line')), + icon=ima.icon('editdelete'), + tip=_("Clear line"), + triggered=self.clear_line) + + clear_action = create_action( + self, + _("Clear shell"), + QKeySequence(CONF.get_shortcut('console', 'Clear shell')), + icon=ima.icon('editclear'), + tip=_("Clear shell contents ('cls' command)"), + triggered=self.clear_terminal) + + add_actions(self.menu, (self.copy_without_prompts_action, + clear_line_action, clear_action)) + + def contextMenuEvent(self, event): + """Reimplements ShellBaseWidget method""" + state = self.has_selected_text() + self.copy_without_prompts_action.setEnabled(state) + ShellBaseWidget.contextMenuEvent(self, event) + + @Slot() + def copy_without_prompts(self): + """Copy text to clipboard without prompts""" + text = self.get_selected_text() + lines = text.split(os.linesep) + for index, line in enumerate(lines): + if line.startswith('>>> ') or line.startswith('... '): + lines[index] = line[4:] + text = os.linesep.join(lines) + QApplication.clipboard().setText(text) + + + #------ Key handlers + def postprocess_keyevent(self, event): + """Process keypress event""" + ShellBaseWidget.postprocess_keyevent(self, event) + + def _key_other(self, text): + """1 character key""" + if self.is_completion_widget_visible(): + self.completion_text += text + + def _key_backspace(self, cursor_position): + """Action for Backspace key""" + if self.has_selected_text(): + self.check_selection() + self.remove_selected_text() + elif self.current_prompt_pos == cursor_position: + # Avoid deleting prompt + return + elif self.is_cursor_on_last_line(): + self.stdkey_backspace() + if self.is_completion_widget_visible(): + # Removing only last character because if there was a selection + # the completion widget would have been canceled + self.completion_text = self.completion_text[:-1] + + def _key_tab(self): + """Action for TAB key""" + if self.is_cursor_on_last_line(): + empty_line = not self.get_current_line_to_cursor().strip() + if empty_line: + self.stdkey_tab() + else: + self.show_code_completion() + + def _key_ctrl_space(self): + """Action for Ctrl+Space""" + if not self.is_completion_widget_visible(): + self.show_code_completion() + + def _key_pageup(self): + """Action for PageUp key""" + pass + + def _key_pagedown(self): + """Action for PageDown key""" + pass + + def _key_escape(self): + """Action for ESCAPE key""" + if self.is_completion_widget_visible(): + self.hide_completion_widget() + + def _key_question(self, text): + """Action for '?'""" + if self.get_current_line_to_cursor(): + last_obj = self.get_last_obj() + if last_obj and not last_obj.isdigit(): + self.show_object_info(last_obj) + self.insert_text(text) + # In case calltip and completion are shown at the same time: + if self.is_completion_widget_visible(): + self.completion_text += '?' + + def _key_parenleft(self, text): + """Action for '('""" + self.hide_completion_widget() + if self.get_current_line_to_cursor(): + last_obj = self.get_last_obj() + if last_obj and not last_obj.isdigit(): + self.insert_text(text) + self.show_object_info(last_obj, call=True) + return + self.insert_text(text) + + def _key_period(self, text): + """Action for '.'""" + self.insert_text(text) + if self.codecompletion_auto: + # Enable auto-completion only if last token isn't a float + last_obj = self.get_last_obj() + if last_obj and not last_obj.isdigit(): + self.show_code_completion() + + + #------ Paste + def paste(self): + """Reimplemented slot to handle multiline paste action""" + text = to_text_string(QApplication.clipboard().text()) + if len(text.splitlines()) > 1: + # Multiline paste + if self.new_input_line: + self.on_new_line() + self.remove_selected_text() # Remove selection, eventually + end = self.get_current_line_from_cursor() + lines = self.get_current_line_to_cursor() + text + end + self.clear_line() + self.execute_lines(lines) + self.move_cursor(-len(end)) + else: + # Standard paste + ShellBaseWidget.paste(self) + + # ------ Code Completion / Calltips + # Methods implemented in child class: + # (e.g. InternalShell) + def get_dir(self, objtxt): + """Return dir(object)""" + raise NotImplementedError + + def get_globals_keys(self): + """Return shell globals() keys""" + raise NotImplementedError + + def get_cdlistdir(self): + """Return shell current directory list dir""" + raise NotImplementedError + + def iscallable(self, objtxt): + """Is object callable?""" + raise NotImplementedError + + def get_arglist(self, objtxt): + """Get func/method argument list""" + raise NotImplementedError + + def get__doc__(self, objtxt): + """Get object __doc__""" + raise NotImplementedError + + def get_doc(self, objtxt): + """Get object documentation dictionary""" + raise NotImplementedError + + def get_source(self, objtxt): + """Get object source""" + raise NotImplementedError + + def is_defined(self, objtxt, force_import=False): + """Return True if object is defined""" + raise NotImplementedError + + def show_completion_widget(self, textlist): + """Show completion widget""" + self.completion_widget.show_list( + textlist, automatic=False, position=None) + + def hide_completion_widget(self, focus_to_parent=True): + """Hide completion widget""" + self.completion_widget.hide(focus_to_parent=focus_to_parent) + + def show_completion_list(self, completions, completion_text=""): + """Display the possible completions""" + if not completions: + return + if not isinstance(completions[0], tuple): + completions = [(c, '') for c in completions] + if len(completions) == 1 and completions[0][0] == completion_text: + return + self.completion_text = completion_text + # Sorting completion list (entries starting with underscore are + # put at the end of the list): + underscore = set([(comp, t) for (comp, t) in completions + if comp.startswith('_')]) + + completions = sorted(set(completions) - underscore, + key=lambda x: str_lower(x[0])) + completions += sorted(underscore, key=lambda x: str_lower(x[0])) + self.show_completion_widget(completions) + + def show_code_completion(self): + """Display a completion list based on the current line""" + # Note: unicode conversion is needed only for ExternalShellBase + text = to_text_string(self.get_current_line_to_cursor()) + last_obj = self.get_last_obj() + if not text: + return + + obj_dir = self.get_dir(last_obj) + if last_obj and obj_dir and text.endswith('.'): + self.show_completion_list(obj_dir) + return + + # Builtins and globals + if not text.endswith('.') and last_obj \ + and re.match(r'[a-zA-Z_0-9]*$', last_obj): + b_k_g = dir(builtins)+self.get_globals_keys()+keyword.kwlist + for objname in b_k_g: + if objname.startswith(last_obj) and objname != last_obj: + self.show_completion_list(b_k_g, completion_text=last_obj) + return + else: + return + + # Looking for an incomplete completion + if last_obj is None: + last_obj = text + dot_pos = last_obj.rfind('.') + if dot_pos != -1: + if dot_pos == len(last_obj)-1: + completion_text = "" + else: + completion_text = last_obj[dot_pos+1:] + last_obj = last_obj[:dot_pos] + completions = self.get_dir(last_obj) + if completions is not None: + self.show_completion_list(completions, + completion_text=completion_text) + return + + # Looking for ' or ": filename completion + q_pos = max([text.rfind("'"), text.rfind('"')]) + if q_pos != -1: + completions = self.get_cdlistdir() + if completions: + self.show_completion_list(completions, + completion_text=text[q_pos+1:]) + return + + #------ Drag'n Drop + def drop_pathlist(self, pathlist): + """Drop path list""" + if pathlist: + files = ["r'%s'" % path for path in pathlist] + if len(files) == 1: + text = files[0] + else: + text = "[" + ", ".join(files) + "]" + if self.new_input_line: + self.on_new_line() + self.insert_text(text) + self.setFocus() + + +class TerminalWidget(ShellBaseWidget): + """ + Terminal widget + """ + COM = 'rem' if os.name == 'nt' else '#' + INITHISTORY = ['%s *** Spyder Terminal History Log ***' % COM, COM,] + SEPARATOR = '%s%s ---(%s)---' % (os.linesep*2, COM, time.ctime()) + + # This signal emits a parsed error traceback text so we can then + # request opening the file that traceback comes from in the Editor. + sig_go_to_error_requested = Signal(str) + + def __init__(self, parent, history_filename, profile=False): + ShellBaseWidget.__init__(self, parent, history_filename, profile) + + #------ Key handlers + def _key_other(self, text): + """1 character key""" + pass + + def _key_backspace(self, cursor_position): + """Action for Backspace key""" + if self.has_selected_text(): + self.check_selection() + self.remove_selected_text() + elif self.current_prompt_pos == cursor_position: + # Avoid deleting prompt + return + elif self.is_cursor_on_last_line(): + self.stdkey_backspace() + + def _key_tab(self): + """Action for TAB key""" + if self.is_cursor_on_last_line(): + self.stdkey_tab() + + def _key_ctrl_space(self): + """Action for Ctrl+Space""" + pass + + def _key_escape(self): + """Action for ESCAPE key""" + self.clear_line() + + def _key_question(self, text): + """Action for '?'""" + self.insert_text(text) + + def _key_parenleft(self, text): + """Action for '('""" + self.insert_text(text) + + def _key_period(self, text): + """Action for '.'""" + self.insert_text(text) + + + #------ Drag'n Drop + def drop_pathlist(self, pathlist): + """Drop path list""" + if pathlist: + files = ['"%s"' % path for path in pathlist] + if len(files) == 1: + text = files[0] + else: + text = " ".join(files) + if self.new_input_line: + self.on_new_line() + self.insert_text(text) + self.setFocus() diff --git a/spyder/plugins/editor/extensions/docstring.py b/spyder/plugins/editor/extensions/docstring.py index a5aca741676..e0205e63ba0 100644 --- a/spyder/plugins/editor/extensions/docstring.py +++ b/spyder/plugins/editor/extensions/docstring.py @@ -1,1034 +1,1034 @@ -# -*- coding: utf-8 -*- -# -# Copyright © Spyder Project Contributors -# Licensed under the terms of the MIT License -# (see spyder/__init__.py for details) - -"""Generate Docstring.""" - -# Standard library imports -import re -from collections import OrderedDict - -# Third party imports -from qtpy.QtGui import QTextCursor -from qtpy.QtCore import Qt -from qtpy.QtWidgets import QMenu - -# Local imports -from spyder.config.manager import CONF -from spyder.py3compat import to_text_string - - -def is_start_of_function(text): - """Return True if text is the beginning of the function definition.""" - if isinstance(text, str): - function_prefix = ['def', 'async def'] - text = text.lstrip() - - for prefix in function_prefix: - if text.startswith(prefix): - return True - - return False - - -def get_indent(text): - """Get indent of text. - - https://stackoverflow.com/questions/2268532/grab-a-lines-whitespace- - indention-with-python - """ - indent = '' - - ret = re.match(r'(\s*)', text) - if ret: - indent = ret.group(1) - - return indent - - -def is_in_scope_forward(text): - """Check if the next empty line could be part of the definition.""" - text = text.replace(r"\"", "").replace(r"\'", "") - scopes = ["'''", '"""', "'", '"'] - indices = [10**6] * 4 # Limits function def length to 10**6 - for i in range(len(scopes)): - if scopes[i] in text: - indices[i] = text.index(scopes[i]) - if min(indices) == 10**6: - return (text.count(")") != text.count("(") or - text.count("]") != text.count("[") or - text.count("}") != text.count("{")) - s = scopes[indices.index(min(indices))] - p = indices[indices.index(min(indices))] - ls = len(s) - if s in text[p + ls:]: - text = text[:p] + text[p + ls:][text[p + ls:].index(s) + ls:] - return is_in_scope_forward(text) - elif ls == 3: - text = text[:p] - return (text.count(")") != text.count("(") or - text.count("]") != text.count("[") or - text.count("}") != text.count("{")) - else: - return False - - -def is_tuple_brackets(text): - """Check if the return type is a tuple.""" - scopes = ["(", "[", "{"] - complements = [")", "]", "}"] - indices = [10**6] * 4 # Limits return type length to 10**6 - for i in range(len(scopes)): - if scopes[i] in text: - indices[i] = text.index(scopes[i]) - if min(indices) == 10**6: - return "," in text - s = complements[indices.index(min(indices))] - p = indices[indices.index(min(indices))] - if s in text[p + 1:]: - text = text[:p] + text[p + 1:][text[p + 1:].index(s) + 1:] - return is_tuple_brackets(text) - else: - return False - - -def is_tuple_strings(text): - """Check if the return type is a string.""" - text = text.replace(r"\"", "").replace(r"\'", "") - scopes = ["'''", '"""', "'", '"'] - indices = [10**6] * 4 # Limits return type length to 10**6 - for i in range(len(scopes)): - if scopes[i] in text: - indices[i] = text.index(scopes[i]) - if min(indices) == 10**6: - return is_tuple_brackets(text) - s = scopes[indices.index(min(indices))] - p = indices[indices.index(min(indices))] - ls = len(s) - if s in text[p + ls:]: - text = text[:p] + text[p + ls:][text[p + ls:].index(s) + ls:] - return is_tuple_strings(text) - else: - return False - - -def is_in_scope_backward(text): - """Check if the next empty line could be part of the definition.""" - return is_in_scope_forward( - text.replace(r"\"", "").replace(r"\'", "")[::-1]) - - -class DocstringWriterExtension(object): - """Class for insert docstring template automatically.""" - - def __init__(self, code_editor): - """Initialize and Add code_editor to the variable.""" - self.code_editor = code_editor - self.quote3 = '"""' - self.quote3_other = "'''" - self.line_number_cursor = None - - @staticmethod - def is_beginning_triple_quotes(text): - """Return True if there are only triple quotes in text.""" - docstring_triggers = ['"""', 'r"""', "'''", "r'''"] - if text.lstrip() in docstring_triggers: - return True - - return False - - def is_end_of_function_definition(self, text, line_number): - """Return True if text is the end of the function definition.""" - text_without_whitespace = "".join(text.split()) - if ( - text_without_whitespace.endswith("):") or - text_without_whitespace.endswith("]:") or - (text_without_whitespace.endswith(":") and - "->" in text_without_whitespace) - ): - return True - elif text_without_whitespace.endswith(":") and line_number > 1: - complete_text = text_without_whitespace - document = self.code_editor.document() - cursor = QTextCursor( - document.findBlockByNumber(line_number - 2)) # previous line - for i in range(line_number - 2, -1, -1): - txt = "".join(str(cursor.block().text()).split()) - if txt.endswith("\\") or is_in_scope_backward(complete_text): - if txt.endswith("\\"): - txt = txt[:-1] - complete_text = txt + complete_text - else: - break - if i != 0: - cursor.movePosition(QTextCursor.PreviousBlock) - if is_start_of_function(complete_text): - return ( - complete_text.endswith("):") or - complete_text.endswith("]:") or - (complete_text.endswith(":") and - "->" in complete_text) - ) - else: - return False - else: - return False - - def get_function_definition_from_first_line(self): - """Get func def when the cursor is located on the first def line.""" - document = self.code_editor.document() - cursor = QTextCursor( - document.findBlockByNumber(self.line_number_cursor - 1)) - - func_text = '' - func_indent = '' - - is_first_line = True - line_number = cursor.blockNumber() + 1 - - number_of_lines = self.code_editor.blockCount() - remain_lines = number_of_lines - line_number + 1 - number_of_lines_of_function = 0 - - for __ in range(min(remain_lines, 20)): - cur_text = to_text_string(cursor.block().text()).rstrip() - - if is_first_line: - if not is_start_of_function(cur_text): - return None - - func_indent = get_indent(cur_text) - is_first_line = False - else: - cur_indent = get_indent(cur_text) - if cur_indent <= func_indent and cur_text.strip() != '': - return None - if is_start_of_function(cur_text): - return None - if (cur_text.strip() == '' and - not is_in_scope_forward(func_text)): - return None - - if len(cur_text) > 0 and cur_text[-1] == '\\': - cur_text = cur_text[:-1] - - func_text += cur_text - number_of_lines_of_function += 1 - - if self.is_end_of_function_definition( - cur_text, line_number + number_of_lines_of_function - 1): - return func_text, number_of_lines_of_function - - cursor.movePosition(QTextCursor.NextBlock) - - return None - - def get_function_definition_from_below_last_line(self): - """Get func def when the cursor is located below the last def line.""" - cursor = self.code_editor.textCursor() - func_text = '' - is_first_line = True - line_number = cursor.blockNumber() + 1 - number_of_lines_of_function = 0 - - for __ in range(min(line_number, 20)): - if cursor.block().blockNumber() == 0: - return None - - cursor.movePosition(QTextCursor.PreviousBlock) - prev_text = to_text_string(cursor.block().text()).rstrip() - - if is_first_line: - if not self.is_end_of_function_definition( - prev_text, line_number - 1): - return None - is_first_line = False - elif self.is_end_of_function_definition( - prev_text, line_number - number_of_lines_of_function - 1): - return None - - if len(prev_text) > 0 and prev_text[-1] == '\\': - prev_text = prev_text[:-1] - - func_text = prev_text + func_text - - number_of_lines_of_function += 1 - if is_start_of_function(prev_text): - return func_text, number_of_lines_of_function - - return None - - def get_function_body(self, func_indent): - """Get the function body text.""" - cursor = self.code_editor.textCursor() - line_number = cursor.blockNumber() + 1 - number_of_lines = self.code_editor.blockCount() - body_list = [] - - for __ in range(number_of_lines - line_number + 1): - text = to_text_string(cursor.block().text()) - text_indent = get_indent(text) - - if text.strip() == '': - pass - elif len(text_indent) <= len(func_indent): - break - - body_list.append(text) - - cursor.movePosition(QTextCursor.NextBlock) - - return '\n'.join(body_list) - - def write_docstring(self): - """Write docstring to editor.""" - line_to_cursor = self.code_editor.get_text('sol', 'cursor') - if self.is_beginning_triple_quotes(line_to_cursor): - cursor = self.code_editor.textCursor() - prev_pos = cursor.position() - - quote = line_to_cursor[-1] - docstring_type = CONF.get('editor', 'docstring_type') - docstring = self._generate_docstring(docstring_type, quote) - - if docstring: - self.code_editor.insert_text(docstring) - - cursor = self.code_editor.textCursor() - cursor.setPosition(prev_pos, QTextCursor.KeepAnchor) - cursor.movePosition(QTextCursor.NextBlock) - cursor.movePosition(QTextCursor.EndOfLine, - QTextCursor.KeepAnchor) - cursor.clearSelection() - self.code_editor.setTextCursor(cursor) - return True - - return False - - def write_docstring_at_first_line_of_function(self): - """Write docstring to editor at mouse position.""" - result = self.get_function_definition_from_first_line() - editor = self.code_editor - if result: - func_text, number_of_line_func = result - line_number_function = (self.line_number_cursor + - number_of_line_func - 1) - - cursor = editor.textCursor() - line_number_cursor = cursor.blockNumber() + 1 - offset = line_number_function - line_number_cursor - if offset > 0: - for __ in range(offset): - cursor.movePosition(QTextCursor.NextBlock) - else: - for __ in range(abs(offset)): - cursor.movePosition(QTextCursor.PreviousBlock) - cursor.movePosition(QTextCursor.EndOfLine, QTextCursor.MoveAnchor) - editor.setTextCursor(cursor) - - indent = get_indent(func_text) - editor.insert_text('\n{}{}"""'.format(indent, editor.indent_chars)) - self.write_docstring() - - def write_docstring_for_shortcut(self): - """Write docstring to editor by shortcut of code editor.""" - # cursor placed below function definition - result = self.get_function_definition_from_below_last_line() - if result is not None: - __, number_of_lines_of_function = result - cursor = self.code_editor.textCursor() - for __ in range(number_of_lines_of_function): - cursor.movePosition(QTextCursor.PreviousBlock) - - self.code_editor.setTextCursor(cursor) - - cursor = self.code_editor.textCursor() - self.line_number_cursor = cursor.blockNumber() + 1 - - self.write_docstring_at_first_line_of_function() - - def _generate_docstring(self, doc_type, quote): - """Generate docstring.""" - docstring = None - - self.quote3 = quote * 3 - if quote == '"': - self.quote3_other = "'''" - else: - self.quote3_other = '"""' - - result = self.get_function_definition_from_below_last_line() - - if result: - func_def, __ = result - func_info = FunctionInfo() - func_info.parse_def(func_def) - - if func_info.has_info: - func_body = self.get_function_body(func_info.func_indent) - if func_body: - func_info.parse_body(func_body) - - if doc_type == 'Numpydoc': - docstring = self._generate_numpy_doc(func_info) - elif doc_type == 'Googledoc': - docstring = self._generate_google_doc(func_info) - elif doc_type == "Sphinxdoc": - docstring = self._generate_sphinx_doc(func_info) - - return docstring - - def _generate_numpy_doc(self, func_info): - """Generate a docstring of numpy type.""" - numpy_doc = '' - - arg_names = func_info.arg_name_list - arg_types = func_info.arg_type_list - arg_values = func_info.arg_value_list - - if len(arg_names) > 0 and arg_names[0] in ('self', 'cls'): - del arg_names[0] - del arg_types[0] - del arg_values[0] - - indent1 = func_info.func_indent + self.code_editor.indent_chars - indent2 = func_info.func_indent + self.code_editor.indent_chars * 2 - - numpy_doc += '\n{}\n'.format(indent1) - - if len(arg_names) > 0: - numpy_doc += '\n{}Parameters'.format(indent1) - numpy_doc += '\n{}----------\n'.format(indent1) - - arg_text = '' - for arg_name, arg_type, arg_value in zip(arg_names, arg_types, - arg_values): - arg_text += '{}{} : '.format(indent1, arg_name) - if arg_type: - arg_text += '{}'.format(arg_type) - else: - arg_text += 'TYPE' - - if arg_value: - arg_text += ', optional' - - arg_text += '\n{}DESCRIPTION.'.format(indent2) - - if arg_value: - arg_value = arg_value.replace(self.quote3, self.quote3_other) - arg_text += ' The default is {}.'.format(arg_value) - - arg_text += '\n' - - numpy_doc += arg_text - - if func_info.raise_list: - numpy_doc += '\n{}Raises'.format(indent1) - numpy_doc += '\n{}------'.format(indent1) - for raise_type in func_info.raise_list: - numpy_doc += '\n{}{}'.format(indent1, raise_type) - numpy_doc += '\n{}DESCRIPTION.'.format(indent2) - numpy_doc += '\n' - - numpy_doc += '\n' - if func_info.has_yield: - header = '{0}Yields\n{0}------\n'.format(indent1) - else: - header = '{0}Returns\n{0}-------\n'.format(indent1) - - return_type_annotated = func_info.return_type_annotated - if return_type_annotated: - return_section = '{}{}{}'.format(header, indent1, - return_type_annotated) - return_section += '\n{}DESCRIPTION.'.format(indent2) - else: - return_element_type = indent1 + '{return_type}\n' + indent2 + \ - 'DESCRIPTION.' - placeholder = return_element_type.format(return_type='TYPE') - return_element_name = indent1 + '{return_name} : ' + \ - placeholder.lstrip() - - try: - return_section = self._generate_docstring_return_section( - func_info.return_value_in_body, header, - return_element_name, return_element_type, placeholder, - indent1) - except (ValueError, IndexError): - return_section = '{}{}None.'.format(header, indent1) - - numpy_doc += return_section - numpy_doc += '\n\n{}{}'.format(indent1, self.quote3) - - return numpy_doc - - def _generate_google_doc(self, func_info): - """Generate a docstring of google type.""" - google_doc = '' - - arg_names = func_info.arg_name_list - arg_types = func_info.arg_type_list - arg_values = func_info.arg_value_list - - if len(arg_names) > 0 and arg_names[0] in ('self', 'cls'): - del arg_names[0] - del arg_types[0] - del arg_values[0] - - indent1 = func_info.func_indent + self.code_editor.indent_chars - indent2 = func_info.func_indent + self.code_editor.indent_chars * 2 - - google_doc += '\n{}\n'.format(indent1) - - if len(arg_names) > 0: - google_doc += '\n{0}Args:\n'.format(indent1) - - arg_text = '' - for arg_name, arg_type, arg_value in zip(arg_names, arg_types, - arg_values): - arg_text += '{}{} '.format(indent2, arg_name) - - arg_text += '(' - if arg_type: - arg_text += '{}'.format(arg_type) - else: - arg_text += 'TYPE' - - if arg_value: - arg_text += ', optional' - arg_text += '):' - - arg_text += ' DESCRIPTION.' - - if arg_value: - arg_value = arg_value.replace(self.quote3, self.quote3_other) - arg_text += ' Defaults to {}.\n'.format(arg_value) - else: - arg_text += '\n' - - google_doc += arg_text - - if func_info.raise_list: - google_doc += '\n{0}Raises:'.format(indent1) - for raise_type in func_info.raise_list: - google_doc += '\n{}{}'.format(indent2, raise_type) - google_doc += ': DESCRIPTION.' - google_doc += '\n' - - google_doc += '\n' - if func_info.has_yield: - header = '{}Yields:\n'.format(indent1) - else: - header = '{}Returns:\n'.format(indent1) - - return_type_annotated = func_info.return_type_annotated - if return_type_annotated: - return_section = '{}{}{}: DESCRIPTION.'.format( - header, indent2, return_type_annotated) - else: - return_element_type = indent2 + '{return_type}: DESCRIPTION.' - placeholder = return_element_type.format(return_type='TYPE') - return_element_name = indent2 + '{return_name} ' + \ - '(TYPE): DESCRIPTION.' - - try: - return_section = self._generate_docstring_return_section( - func_info.return_value_in_body, header, - return_element_name, return_element_type, placeholder, - indent2) - except (ValueError, IndexError): - return_section = '{}{}None.'.format(header, indent2) - - google_doc += return_section - google_doc += '\n\n{}{}'.format(indent1, self.quote3) - - return google_doc - - def _generate_sphinx_doc(self, func_info): - """Generate a docstring of sphinx type.""" - sphinx_doc = '' - - arg_names = func_info.arg_name_list - arg_types = func_info.arg_type_list - arg_values = func_info.arg_value_list - - if len(arg_names) > 0 and arg_names[0] in ('self', 'cls'): - del arg_names[0] - del arg_types[0] - del arg_values[0] - - indent1 = func_info.func_indent + self.code_editor.indent_chars - - sphinx_doc += '\n{}\n'.format(indent1) - - arg_text = '' - for arg_name, arg_type, arg_value in zip(arg_names, arg_types, - arg_values): - arg_text += '{}:param {}: DESCRIPTION'.format(indent1, arg_name) - - if arg_value: - arg_value = arg_value.replace(self.quote3, self.quote3_other) - arg_text += ', defaults to {}\n'.format(arg_value) - else: - arg_text += '\n' - - arg_text += '{}:type {}: '.format(indent1, arg_name) - - if arg_type: - arg_text += '{}'.format(arg_type) - else: - arg_text += 'TYPE' - - if arg_value: - arg_text += ', optional' - arg_text += '\n' - - sphinx_doc += arg_text - - if func_info.raise_list: - for raise_type in func_info.raise_list: - sphinx_doc += '{}:raises {}: DESCRIPTION\n'.format(indent1, - raise_type) - - if func_info.has_yield: - header = '{}:yield:'.format(indent1) - else: - header = '{}:return:'.format(indent1) - - return_type_annotated = func_info.return_type_annotated - if return_type_annotated: - return_section = '{} DESCRIPTION\n'.format(header) - return_section += '{}:rtype: {}'.format(indent1, - return_type_annotated) - else: - return_section = '{} DESCRIPTION\n'.format(header) - return_section += '{}:rtype: TYPE'.format(indent1) - - sphinx_doc += return_section - sphinx_doc += '\n\n{}{}'.format(indent1, self.quote3) - - return sphinx_doc - - @staticmethod - def find_top_level_bracket_locations(string_toparse): - """Get the locations of top-level brackets in a string.""" - bracket_stack = [] - replace_args_list = [] - bracket_type = None - literal_type = '' - brackets = {'(': ')', '[': ']', '{': '}'} - for idx, character in enumerate(string_toparse): - if (not bracket_stack and character in brackets.keys() - or character == bracket_type): - bracket_stack.append(idx) - bracket_type = character - elif bracket_type and character == brackets[bracket_type]: - begin_idx = bracket_stack.pop() - if not bracket_stack: - if not literal_type: - if bracket_type == '(': - literal_type = '(None)' - elif bracket_type == '[': - literal_type = '[list]' - elif bracket_type == '{': - if idx - begin_idx <= 1: - literal_type = '{dict}' - else: - literal_type = '{set}' - replace_args_list.append( - (string_toparse[begin_idx:idx + 1], - literal_type, 1)) - bracket_type = None - literal_type = '' - elif len(bracket_stack) == 1: - if bracket_type == '(' and character == ',': - literal_type = '(tuple)' - elif bracket_type == '{' and character == ':': - literal_type = '{dict}' - elif bracket_type == '(' and character == ':': - literal_type = '[slice]' - - if bracket_stack: - raise IndexError('Bracket mismatch') - for replace_args in replace_args_list: - string_toparse = string_toparse.replace(*replace_args) - return string_toparse - - @staticmethod - def parse_return_elements(return_vals_group, return_element_name, - return_element_type, placeholder): - """Return the appropriate text for a group of return elements.""" - all_eq = (return_vals_group.count(return_vals_group[0]) - == len(return_vals_group)) - if all([{'[list]', '(tuple)', '{dict}', '{set}'}.issuperset( - return_vals_group)]) and all_eq: - return return_element_type.format( - return_type=return_vals_group[0][1:-1]) - # Output placeholder if special Python chars present in name - py_chars = {' ', '+', '-', '*', '/', '%', '@', '<', '>', '&', '|', '^', - '~', '=', ',', ':', ';', '#', '(', '[', '{', '}', ']', - ')', } - if any([any([py_char in return_val for py_char in py_chars]) - for return_val in return_vals_group]): - return placeholder - # Output str type and no name if only string literals - if all(['"' in return_val or '\'' in return_val - for return_val in return_vals_group]): - return return_element_type.format(return_type='str') - # Output bool type and no name if only bool literals - if {'True', 'False'}.issuperset(return_vals_group): - return return_element_type.format(return_type='bool') - # Output numeric types and no name if only numeric literals - try: - [float(return_val) for return_val in return_vals_group] - num_not_int = 0 - for return_val in return_vals_group: - try: - int(return_val) - except ValueError: # If not an integer (EAFP) - num_not_int = num_not_int + 1 - if num_not_int == 0: - return return_element_type.format(return_type='int') - elif num_not_int == len(return_vals_group): - return return_element_type.format(return_type='float') - else: - return return_element_type.format(return_type='numeric') - except ValueError: # Not a numeric if float conversion didn't work - pass - # If names are not equal, don't contain "." or are a builtin - if ({'self', 'cls', 'None'}.isdisjoint(return_vals_group) and all_eq - and all(['.' not in return_val - for return_val in return_vals_group])): - return return_element_name.format(return_name=return_vals_group[0]) - return placeholder - - def _generate_docstring_return_section(self, return_vals, header, - return_element_name, - return_element_type, - placeholder, indent): - """Generate the Returns section of a function/method docstring.""" - # If all return values are None, return none - non_none_vals = [return_val for return_val in return_vals - if return_val and return_val != 'None'] - if not non_none_vals: - return header + indent + 'None.' - - # Get only values with matching brackets that can be cleaned up - non_none_vals = [return_val.strip(' ()\t\n').rstrip(',') - for return_val in non_none_vals] - non_none_vals = [re.sub('([\"\'])(?:(?=(\\\\?))\\2.)*?\\1', - '"string"', return_val) - for return_val in non_none_vals] - unambiguous_vals = [] - for return_val in non_none_vals: - try: - cleaned_val = self.find_top_level_bracket_locations(return_val) - except IndexError: - continue - unambiguous_vals.append(cleaned_val) - if not unambiguous_vals: - return header + placeholder - - # If remaining are a mix of tuples and not, return single placeholder - single_vals, tuple_vals = [], [] - for return_val in unambiguous_vals: - (tuple_vals.append(return_val) if ',' in return_val - else single_vals.append(return_val)) - if single_vals and tuple_vals: - return header + placeholder - - # If return values are tuples of different length, return a placeholder - if tuple_vals: - num_elements = [return_val.count(',') + 1 - for return_val in tuple_vals] - if num_elements.count(num_elements[0]) != len(num_elements): - return header + placeholder - num_elements = num_elements[0] - else: - num_elements = 1 - - # If all have the same len but some ambiguous return that placeholders - if len(unambiguous_vals) != len(non_none_vals): - return header + '\n'.join( - [placeholder for __ in range(num_elements)]) - - # Handle tuple (or single) values position by position - return_vals_grouped = zip(*[ - [return_element.strip() for return_element in - return_val.split(',')] - for return_val in unambiguous_vals]) - return_elements_out = [] - for return_vals_group in return_vals_grouped: - return_elements_out.append( - self.parse_return_elements(return_vals_group, - return_element_name, - return_element_type, - placeholder)) - - return header + '\n'.join(return_elements_out) - - -class FunctionInfo(object): - """Parse function definition text.""" - - def __init__(self): - """.""" - self.has_info = False - self.func_text = '' - self.args_text = '' - self.func_indent = '' - self.arg_name_list = [] - self.arg_type_list = [] - self.arg_value_list = [] - self.return_type_annotated = None - self.return_value_in_body = [] - self.raise_list = None - self.has_yield = False - - @staticmethod - def is_char_in_pairs(pos_char, pairs): - """Return True if the character is in pairs of brackets or quotes.""" - for pos_left, pos_right in pairs.items(): - if pos_left < pos_char < pos_right: - return True - - return False - - @staticmethod - def _find_quote_position(text): - """Return the start and end position of pairs of quotes.""" - pos = {} - is_found_left_quote = False - - for idx, character in enumerate(text): - if is_found_left_quote is False: - if character == "'" or character == '"': - is_found_left_quote = True - quote = character - left_pos = idx - else: - if character == quote and text[idx - 1] != '\\': - pos[left_pos] = idx - is_found_left_quote = False - - if is_found_left_quote: - raise IndexError("No matching close quote at: " + str(left_pos)) - - return pos - - def _find_bracket_position(self, text, bracket_left, bracket_right, - pos_quote): - """Return the start and end position of pairs of brackets. - - https://stackoverflow.com/questions/29991917/ - indices-of-matching-parentheses-in-python - """ - pos = {} - pstack = [] - - for idx, character in enumerate(text): - if character == bracket_left and \ - not self.is_char_in_pairs(idx, pos_quote): - pstack.append(idx) - elif character == bracket_right and \ - not self.is_char_in_pairs(idx, pos_quote): - if len(pstack) == 0: - raise IndexError( - "No matching closing parens at: " + str(idx)) - pos[pstack.pop()] = idx - - if len(pstack) > 0: - raise IndexError( - "No matching opening parens at: " + str(pstack.pop())) - - return pos - - def split_arg_to_name_type_value(self, args_list): - """Split argument text to name, type, value.""" - for arg in args_list: - arg_type = None - arg_value = None - - has_type = False - has_value = False - - pos_colon = arg.find(':') - pos_equal = arg.find('=') - - if pos_equal > -1: - has_value = True - - if pos_colon > -1: - if not has_value: - has_type = True - elif pos_equal > pos_colon: # exception for def foo(arg1=":") - has_type = True - - if has_value and has_type: - arg_name = arg[0:pos_colon].strip() - arg_type = arg[pos_colon + 1:pos_equal].strip() - arg_value = arg[pos_equal + 1:].strip() - elif not has_value and has_type: - arg_name = arg[0:pos_colon].strip() - arg_type = arg[pos_colon + 1:].strip() - elif has_value and not has_type: - arg_name = arg[0:pos_equal].strip() - arg_value = arg[pos_equal + 1:].strip() - else: - arg_name = arg.strip() - - self.arg_name_list.append(arg_name) - self.arg_type_list.append(arg_type) - self.arg_value_list.append(arg_value) - - def split_args_text_to_list(self, args_text): - """Split the text including multiple arguments to list. - - This function uses a comma to separate arguments and ignores a comma in - brackets and quotes. - """ - args_list = [] - idx_find_start = 0 - idx_arg_start = 0 - - try: - pos_quote = self._find_quote_position(args_text) - pos_round = self._find_bracket_position(args_text, '(', ')', - pos_quote) - pos_curly = self._find_bracket_position(args_text, '{', '}', - pos_quote) - pos_square = self._find_bracket_position(args_text, '[', ']', - pos_quote) - except IndexError: - return None - - while True: - pos_comma = args_text.find(',', idx_find_start) - - if pos_comma == -1: - break - - idx_find_start = pos_comma + 1 - - if self.is_char_in_pairs(pos_comma, pos_round) or \ - self.is_char_in_pairs(pos_comma, pos_curly) or \ - self.is_char_in_pairs(pos_comma, pos_square) or \ - self.is_char_in_pairs(pos_comma, pos_quote): - continue - - args_list.append(args_text[idx_arg_start:pos_comma]) - idx_arg_start = pos_comma + 1 - - if idx_arg_start < len(args_text): - args_list.append(args_text[idx_arg_start:]) - - return args_list - - def parse_def(self, text): - """Parse the function definition text.""" - self.__init__() - - if not is_start_of_function(text): - return - - self.func_indent = get_indent(text) - - text = text.strip() - - return_type_re = re.search( - r'->[ ]*([\"\'a-zA-Z0-9_,()\[\] ]*):$', text) - if return_type_re: - self.return_type_annotated = return_type_re.group(1).strip(" ()\\") - if is_tuple_strings(self.return_type_annotated): - self.return_type_annotated = ( - "(" + self.return_type_annotated + ")" - ) - text_end = text.rfind(return_type_re.group(0)) - else: - self.return_type_annotated = None - text_end = len(text) - - pos_args_start = text.find('(') + 1 - pos_args_end = text.rfind(')', pos_args_start, text_end) - - self.args_text = text[pos_args_start:pos_args_end] - - args_list = self.split_args_text_to_list(self.args_text) - if args_list is not None: - self.has_info = True - self.split_arg_to_name_type_value(args_list) - - def parse_body(self, text): - """Parse the function body text.""" - re_raise = re.findall(r'[ \t]raise ([a-zA-Z0-9_]*)', text) - if len(re_raise) > 0: - self.raise_list = [x.strip() for x in re_raise] - # remove duplicates from list while keeping it in the order - # in python 2.7 - # stackoverflow.com/questions/7961363/removing-duplicates-in-lists - self.raise_list = list(OrderedDict.fromkeys(self.raise_list)) - - re_yield = re.search(r'[ \t]yield ', text) - if re_yield: - self.has_yield = True - - # get return value - pattern_return = r'return |yield ' - line_list = text.split('\n') - is_found_return = False - line_return_tmp = '' - - for line in line_list: - line = line.strip() - - if is_found_return is False: - if re.match(pattern_return, line): - is_found_return = True - - if is_found_return: - line_return_tmp += line - # check the integrity of line - try: - pos_quote = self._find_quote_position(line_return_tmp) - - if line_return_tmp[-1] == '\\': - line_return_tmp = line_return_tmp[:-1] - continue - - self._find_bracket_position(line_return_tmp, '(', ')', - pos_quote) - self._find_bracket_position(line_return_tmp, '{', '}', - pos_quote) - self._find_bracket_position(line_return_tmp, '[', ']', - pos_quote) - except IndexError: - continue - - return_value = re.sub(pattern_return, '', line_return_tmp) - self.return_value_in_body.append(return_value) - - is_found_return = False - line_return_tmp = '' - - -class QMenuOnlyForEnter(QMenu): - """The class executes the selected action when "enter key" is input. - - If a input of keyboard is not the "enter key", the menu is closed and - the input is inserted to code editor. - """ - - def __init__(self, code_editor): - """Init QMenu.""" - super(QMenuOnlyForEnter, self).__init__(code_editor) - self.code_editor = code_editor - - def keyPressEvent(self, event): - """Close the instance if key is not enter key.""" - key = event.key() - if key not in (Qt.Key_Enter, Qt.Key_Return): - self.code_editor.keyPressEvent(event) - self.close() - else: - super(QMenuOnlyForEnter, self).keyPressEvent(event) +# -*- coding: utf-8 -*- +# +# Copyright © Spyder Project Contributors +# Licensed under the terms of the MIT License +# (see spyder/__init__.py for details) + +"""Generate Docstring.""" + +# Standard library imports +import re +from collections import OrderedDict + +# Third party imports +from qtpy.QtGui import QTextCursor +from qtpy.QtCore import Qt +from qtpy.QtWidgets import QMenu + +# Local imports +from spyder.config.manager import CONF +from spyder.py3compat import to_text_string + + +def is_start_of_function(text): + """Return True if text is the beginning of the function definition.""" + if isinstance(text, str): + function_prefix = ['def', 'async def'] + text = text.lstrip() + + for prefix in function_prefix: + if text.startswith(prefix): + return True + + return False + + +def get_indent(text): + """Get indent of text. + + https://stackoverflow.com/questions/2268532/grab-a-lines-whitespace- + indention-with-python + """ + indent = '' + + ret = re.match(r'(\s*)', text) + if ret: + indent = ret.group(1) + + return indent + + +def is_in_scope_forward(text): + """Check if the next empty line could be part of the definition.""" + text = text.replace(r"\"", "").replace(r"\'", "") + scopes = ["'''", '"""', "'", '"'] + indices = [10**6] * 4 # Limits function def length to 10**6 + for i in range(len(scopes)): + if scopes[i] in text: + indices[i] = text.index(scopes[i]) + if min(indices) == 10**6: + return (text.count(")") != text.count("(") or + text.count("]") != text.count("[") or + text.count("}") != text.count("{")) + s = scopes[indices.index(min(indices))] + p = indices[indices.index(min(indices))] + ls = len(s) + if s in text[p + ls:]: + text = text[:p] + text[p + ls:][text[p + ls:].index(s) + ls:] + return is_in_scope_forward(text) + elif ls == 3: + text = text[:p] + return (text.count(")") != text.count("(") or + text.count("]") != text.count("[") or + text.count("}") != text.count("{")) + else: + return False + + +def is_tuple_brackets(text): + """Check if the return type is a tuple.""" + scopes = ["(", "[", "{"] + complements = [")", "]", "}"] + indices = [10**6] * 4 # Limits return type length to 10**6 + for i in range(len(scopes)): + if scopes[i] in text: + indices[i] = text.index(scopes[i]) + if min(indices) == 10**6: + return "," in text + s = complements[indices.index(min(indices))] + p = indices[indices.index(min(indices))] + if s in text[p + 1:]: + text = text[:p] + text[p + 1:][text[p + 1:].index(s) + 1:] + return is_tuple_brackets(text) + else: + return False + + +def is_tuple_strings(text): + """Check if the return type is a string.""" + text = text.replace(r"\"", "").replace(r"\'", "") + scopes = ["'''", '"""', "'", '"'] + indices = [10**6] * 4 # Limits return type length to 10**6 + for i in range(len(scopes)): + if scopes[i] in text: + indices[i] = text.index(scopes[i]) + if min(indices) == 10**6: + return is_tuple_brackets(text) + s = scopes[indices.index(min(indices))] + p = indices[indices.index(min(indices))] + ls = len(s) + if s in text[p + ls:]: + text = text[:p] + text[p + ls:][text[p + ls:].index(s) + ls:] + return is_tuple_strings(text) + else: + return False + + +def is_in_scope_backward(text): + """Check if the next empty line could be part of the definition.""" + return is_in_scope_forward( + text.replace(r"\"", "").replace(r"\'", "")[::-1]) + + +class DocstringWriterExtension(object): + """Class for insert docstring template automatically.""" + + def __init__(self, code_editor): + """Initialize and Add code_editor to the variable.""" + self.code_editor = code_editor + self.quote3 = '"""' + self.quote3_other = "'''" + self.line_number_cursor = None + + @staticmethod + def is_beginning_triple_quotes(text): + """Return True if there are only triple quotes in text.""" + docstring_triggers = ['"""', 'r"""', "'''", "r'''"] + if text.lstrip() in docstring_triggers: + return True + + return False + + def is_end_of_function_definition(self, text, line_number): + """Return True if text is the end of the function definition.""" + text_without_whitespace = "".join(text.split()) + if ( + text_without_whitespace.endswith("):") or + text_without_whitespace.endswith("]:") or + (text_without_whitespace.endswith(":") and + "->" in text_without_whitespace) + ): + return True + elif text_without_whitespace.endswith(":") and line_number > 1: + complete_text = text_without_whitespace + document = self.code_editor.document() + cursor = QTextCursor( + document.findBlockByNumber(line_number - 2)) # previous line + for i in range(line_number - 2, -1, -1): + txt = "".join(str(cursor.block().text()).split()) + if txt.endswith("\\") or is_in_scope_backward(complete_text): + if txt.endswith("\\"): + txt = txt[:-1] + complete_text = txt + complete_text + else: + break + if i != 0: + cursor.movePosition(QTextCursor.PreviousBlock) + if is_start_of_function(complete_text): + return ( + complete_text.endswith("):") or + complete_text.endswith("]:") or + (complete_text.endswith(":") and + "->" in complete_text) + ) + else: + return False + else: + return False + + def get_function_definition_from_first_line(self): + """Get func def when the cursor is located on the first def line.""" + document = self.code_editor.document() + cursor = QTextCursor( + document.findBlockByNumber(self.line_number_cursor - 1)) + + func_text = '' + func_indent = '' + + is_first_line = True + line_number = cursor.blockNumber() + 1 + + number_of_lines = self.code_editor.blockCount() + remain_lines = number_of_lines - line_number + 1 + number_of_lines_of_function = 0 + + for __ in range(min(remain_lines, 20)): + cur_text = to_text_string(cursor.block().text()).rstrip() + + if is_first_line: + if not is_start_of_function(cur_text): + return None + + func_indent = get_indent(cur_text) + is_first_line = False + else: + cur_indent = get_indent(cur_text) + if cur_indent <= func_indent and cur_text.strip() != '': + return None + if is_start_of_function(cur_text): + return None + if (cur_text.strip() == '' and + not is_in_scope_forward(func_text)): + return None + + if len(cur_text) > 0 and cur_text[-1] == '\\': + cur_text = cur_text[:-1] + + func_text += cur_text + number_of_lines_of_function += 1 + + if self.is_end_of_function_definition( + cur_text, line_number + number_of_lines_of_function - 1): + return func_text, number_of_lines_of_function + + cursor.movePosition(QTextCursor.NextBlock) + + return None + + def get_function_definition_from_below_last_line(self): + """Get func def when the cursor is located below the last def line.""" + cursor = self.code_editor.textCursor() + func_text = '' + is_first_line = True + line_number = cursor.blockNumber() + 1 + number_of_lines_of_function = 0 + + for __ in range(min(line_number, 20)): + if cursor.block().blockNumber() == 0: + return None + + cursor.movePosition(QTextCursor.PreviousBlock) + prev_text = to_text_string(cursor.block().text()).rstrip() + + if is_first_line: + if not self.is_end_of_function_definition( + prev_text, line_number - 1): + return None + is_first_line = False + elif self.is_end_of_function_definition( + prev_text, line_number - number_of_lines_of_function - 1): + return None + + if len(prev_text) > 0 and prev_text[-1] == '\\': + prev_text = prev_text[:-1] + + func_text = prev_text + func_text + + number_of_lines_of_function += 1 + if is_start_of_function(prev_text): + return func_text, number_of_lines_of_function + + return None + + def get_function_body(self, func_indent): + """Get the function body text.""" + cursor = self.code_editor.textCursor() + line_number = cursor.blockNumber() + 1 + number_of_lines = self.code_editor.blockCount() + body_list = [] + + for __ in range(number_of_lines - line_number + 1): + text = to_text_string(cursor.block().text()) + text_indent = get_indent(text) + + if text.strip() == '': + pass + elif len(text_indent) <= len(func_indent): + break + + body_list.append(text) + + cursor.movePosition(QTextCursor.NextBlock) + + return '\n'.join(body_list) + + def write_docstring(self): + """Write docstring to editor.""" + line_to_cursor = self.code_editor.get_text('sol', 'cursor') + if self.is_beginning_triple_quotes(line_to_cursor): + cursor = self.code_editor.textCursor() + prev_pos = cursor.position() + + quote = line_to_cursor[-1] + docstring_type = CONF.get('editor', 'docstring_type') + docstring = self._generate_docstring(docstring_type, quote) + + if docstring: + self.code_editor.insert_text(docstring) + + cursor = self.code_editor.textCursor() + cursor.setPosition(prev_pos, QTextCursor.KeepAnchor) + cursor.movePosition(QTextCursor.NextBlock) + cursor.movePosition(QTextCursor.EndOfLine, + QTextCursor.KeepAnchor) + cursor.clearSelection() + self.code_editor.setTextCursor(cursor) + return True + + return False + + def write_docstring_at_first_line_of_function(self): + """Write docstring to editor at mouse position.""" + result = self.get_function_definition_from_first_line() + editor = self.code_editor + if result: + func_text, number_of_line_func = result + line_number_function = (self.line_number_cursor + + number_of_line_func - 1) + + cursor = editor.textCursor() + line_number_cursor = cursor.blockNumber() + 1 + offset = line_number_function - line_number_cursor + if offset > 0: + for __ in range(offset): + cursor.movePosition(QTextCursor.NextBlock) + else: + for __ in range(abs(offset)): + cursor.movePosition(QTextCursor.PreviousBlock) + cursor.movePosition(QTextCursor.EndOfLine, QTextCursor.MoveAnchor) + editor.setTextCursor(cursor) + + indent = get_indent(func_text) + editor.insert_text('\n{}{}"""'.format(indent, editor.indent_chars)) + self.write_docstring() + + def write_docstring_for_shortcut(self): + """Write docstring to editor by shortcut of code editor.""" + # cursor placed below function definition + result = self.get_function_definition_from_below_last_line() + if result is not None: + __, number_of_lines_of_function = result + cursor = self.code_editor.textCursor() + for __ in range(number_of_lines_of_function): + cursor.movePosition(QTextCursor.PreviousBlock) + + self.code_editor.setTextCursor(cursor) + + cursor = self.code_editor.textCursor() + self.line_number_cursor = cursor.blockNumber() + 1 + + self.write_docstring_at_first_line_of_function() + + def _generate_docstring(self, doc_type, quote): + """Generate docstring.""" + docstring = None + + self.quote3 = quote * 3 + if quote == '"': + self.quote3_other = "'''" + else: + self.quote3_other = '"""' + + result = self.get_function_definition_from_below_last_line() + + if result: + func_def, __ = result + func_info = FunctionInfo() + func_info.parse_def(func_def) + + if func_info.has_info: + func_body = self.get_function_body(func_info.func_indent) + if func_body: + func_info.parse_body(func_body) + + if doc_type == 'Numpydoc': + docstring = self._generate_numpy_doc(func_info) + elif doc_type == 'Googledoc': + docstring = self._generate_google_doc(func_info) + elif doc_type == "Sphinxdoc": + docstring = self._generate_sphinx_doc(func_info) + + return docstring + + def _generate_numpy_doc(self, func_info): + """Generate a docstring of numpy type.""" + numpy_doc = '' + + arg_names = func_info.arg_name_list + arg_types = func_info.arg_type_list + arg_values = func_info.arg_value_list + + if len(arg_names) > 0 and arg_names[0] in ('self', 'cls'): + del arg_names[0] + del arg_types[0] + del arg_values[0] + + indent1 = func_info.func_indent + self.code_editor.indent_chars + indent2 = func_info.func_indent + self.code_editor.indent_chars * 2 + + numpy_doc += '\n{}\n'.format(indent1) + + if len(arg_names) > 0: + numpy_doc += '\n{}Parameters'.format(indent1) + numpy_doc += '\n{}----------\n'.format(indent1) + + arg_text = '' + for arg_name, arg_type, arg_value in zip(arg_names, arg_types, + arg_values): + arg_text += '{}{} : '.format(indent1, arg_name) + if arg_type: + arg_text += '{}'.format(arg_type) + else: + arg_text += 'TYPE' + + if arg_value: + arg_text += ', optional' + + arg_text += '\n{}DESCRIPTION.'.format(indent2) + + if arg_value: + arg_value = arg_value.replace(self.quote3, self.quote3_other) + arg_text += ' The default is {}.'.format(arg_value) + + arg_text += '\n' + + numpy_doc += arg_text + + if func_info.raise_list: + numpy_doc += '\n{}Raises'.format(indent1) + numpy_doc += '\n{}------'.format(indent1) + for raise_type in func_info.raise_list: + numpy_doc += '\n{}{}'.format(indent1, raise_type) + numpy_doc += '\n{}DESCRIPTION.'.format(indent2) + numpy_doc += '\n' + + numpy_doc += '\n' + if func_info.has_yield: + header = '{0}Yields\n{0}------\n'.format(indent1) + else: + header = '{0}Returns\n{0}-------\n'.format(indent1) + + return_type_annotated = func_info.return_type_annotated + if return_type_annotated: + return_section = '{}{}{}'.format(header, indent1, + return_type_annotated) + return_section += '\n{}DESCRIPTION.'.format(indent2) + else: + return_element_type = indent1 + '{return_type}\n' + indent2 + \ + 'DESCRIPTION.' + placeholder = return_element_type.format(return_type='TYPE') + return_element_name = indent1 + '{return_name} : ' + \ + placeholder.lstrip() + + try: + return_section = self._generate_docstring_return_section( + func_info.return_value_in_body, header, + return_element_name, return_element_type, placeholder, + indent1) + except (ValueError, IndexError): + return_section = '{}{}None.'.format(header, indent1) + + numpy_doc += return_section + numpy_doc += '\n\n{}{}'.format(indent1, self.quote3) + + return numpy_doc + + def _generate_google_doc(self, func_info): + """Generate a docstring of google type.""" + google_doc = '' + + arg_names = func_info.arg_name_list + arg_types = func_info.arg_type_list + arg_values = func_info.arg_value_list + + if len(arg_names) > 0 and arg_names[0] in ('self', 'cls'): + del arg_names[0] + del arg_types[0] + del arg_values[0] + + indent1 = func_info.func_indent + self.code_editor.indent_chars + indent2 = func_info.func_indent + self.code_editor.indent_chars * 2 + + google_doc += '\n{}\n'.format(indent1) + + if len(arg_names) > 0: + google_doc += '\n{0}Args:\n'.format(indent1) + + arg_text = '' + for arg_name, arg_type, arg_value in zip(arg_names, arg_types, + arg_values): + arg_text += '{}{} '.format(indent2, arg_name) + + arg_text += '(' + if arg_type: + arg_text += '{}'.format(arg_type) + else: + arg_text += 'TYPE' + + if arg_value: + arg_text += ', optional' + arg_text += '):' + + arg_text += ' DESCRIPTION.' + + if arg_value: + arg_value = arg_value.replace(self.quote3, self.quote3_other) + arg_text += ' Defaults to {}.\n'.format(arg_value) + else: + arg_text += '\n' + + google_doc += arg_text + + if func_info.raise_list: + google_doc += '\n{0}Raises:'.format(indent1) + for raise_type in func_info.raise_list: + google_doc += '\n{}{}'.format(indent2, raise_type) + google_doc += ': DESCRIPTION.' + google_doc += '\n' + + google_doc += '\n' + if func_info.has_yield: + header = '{}Yields:\n'.format(indent1) + else: + header = '{}Returns:\n'.format(indent1) + + return_type_annotated = func_info.return_type_annotated + if return_type_annotated: + return_section = '{}{}{}: DESCRIPTION.'.format( + header, indent2, return_type_annotated) + else: + return_element_type = indent2 + '{return_type}: DESCRIPTION.' + placeholder = return_element_type.format(return_type='TYPE') + return_element_name = indent2 + '{return_name} ' + \ + '(TYPE): DESCRIPTION.' + + try: + return_section = self._generate_docstring_return_section( + func_info.return_value_in_body, header, + return_element_name, return_element_type, placeholder, + indent2) + except (ValueError, IndexError): + return_section = '{}{}None.'.format(header, indent2) + + google_doc += return_section + google_doc += '\n\n{}{}'.format(indent1, self.quote3) + + return google_doc + + def _generate_sphinx_doc(self, func_info): + """Generate a docstring of sphinx type.""" + sphinx_doc = '' + + arg_names = func_info.arg_name_list + arg_types = func_info.arg_type_list + arg_values = func_info.arg_value_list + + if len(arg_names) > 0 and arg_names[0] in ('self', 'cls'): + del arg_names[0] + del arg_types[0] + del arg_values[0] + + indent1 = func_info.func_indent + self.code_editor.indent_chars + + sphinx_doc += '\n{}\n'.format(indent1) + + arg_text = '' + for arg_name, arg_type, arg_value in zip(arg_names, arg_types, + arg_values): + arg_text += '{}:param {}: DESCRIPTION'.format(indent1, arg_name) + + if arg_value: + arg_value = arg_value.replace(self.quote3, self.quote3_other) + arg_text += ', defaults to {}\n'.format(arg_value) + else: + arg_text += '\n' + + arg_text += '{}:type {}: '.format(indent1, arg_name) + + if arg_type: + arg_text += '{}'.format(arg_type) + else: + arg_text += 'TYPE' + + if arg_value: + arg_text += ', optional' + arg_text += '\n' + + sphinx_doc += arg_text + + if func_info.raise_list: + for raise_type in func_info.raise_list: + sphinx_doc += '{}:raises {}: DESCRIPTION\n'.format(indent1, + raise_type) + + if func_info.has_yield: + header = '{}:yield:'.format(indent1) + else: + header = '{}:return:'.format(indent1) + + return_type_annotated = func_info.return_type_annotated + if return_type_annotated: + return_section = '{} DESCRIPTION\n'.format(header) + return_section += '{}:rtype: {}'.format(indent1, + return_type_annotated) + else: + return_section = '{} DESCRIPTION\n'.format(header) + return_section += '{}:rtype: TYPE'.format(indent1) + + sphinx_doc += return_section + sphinx_doc += '\n\n{}{}'.format(indent1, self.quote3) + + return sphinx_doc + + @staticmethod + def find_top_level_bracket_locations(string_toparse): + """Get the locations of top-level brackets in a string.""" + bracket_stack = [] + replace_args_list = [] + bracket_type = None + literal_type = '' + brackets = {'(': ')', '[': ']', '{': '}'} + for idx, character in enumerate(string_toparse): + if (not bracket_stack and character in brackets.keys() + or character == bracket_type): + bracket_stack.append(idx) + bracket_type = character + elif bracket_type and character == brackets[bracket_type]: + begin_idx = bracket_stack.pop() + if not bracket_stack: + if not literal_type: + if bracket_type == '(': + literal_type = '(None)' + elif bracket_type == '[': + literal_type = '[list]' + elif bracket_type == '{': + if idx - begin_idx <= 1: + literal_type = '{dict}' + else: + literal_type = '{set}' + replace_args_list.append( + (string_toparse[begin_idx:idx + 1], + literal_type, 1)) + bracket_type = None + literal_type = '' + elif len(bracket_stack) == 1: + if bracket_type == '(' and character == ',': + literal_type = '(tuple)' + elif bracket_type == '{' and character == ':': + literal_type = '{dict}' + elif bracket_type == '(' and character == ':': + literal_type = '[slice]' + + if bracket_stack: + raise IndexError('Bracket mismatch') + for replace_args in replace_args_list: + string_toparse = string_toparse.replace(*replace_args) + return string_toparse + + @staticmethod + def parse_return_elements(return_vals_group, return_element_name, + return_element_type, placeholder): + """Return the appropriate text for a group of return elements.""" + all_eq = (return_vals_group.count(return_vals_group[0]) + == len(return_vals_group)) + if all([{'[list]', '(tuple)', '{dict}', '{set}'}.issuperset( + return_vals_group)]) and all_eq: + return return_element_type.format( + return_type=return_vals_group[0][1:-1]) + # Output placeholder if special Python chars present in name + py_chars = {' ', '+', '-', '*', '/', '%', '@', '<', '>', '&', '|', '^', + '~', '=', ',', ':', ';', '#', '(', '[', '{', '}', ']', + ')', } + if any([any([py_char in return_val for py_char in py_chars]) + for return_val in return_vals_group]): + return placeholder + # Output str type and no name if only string literals + if all(['"' in return_val or '\'' in return_val + for return_val in return_vals_group]): + return return_element_type.format(return_type='str') + # Output bool type and no name if only bool literals + if {'True', 'False'}.issuperset(return_vals_group): + return return_element_type.format(return_type='bool') + # Output numeric types and no name if only numeric literals + try: + [float(return_val) for return_val in return_vals_group] + num_not_int = 0 + for return_val in return_vals_group: + try: + int(return_val) + except ValueError: # If not an integer (EAFP) + num_not_int = num_not_int + 1 + if num_not_int == 0: + return return_element_type.format(return_type='int') + elif num_not_int == len(return_vals_group): + return return_element_type.format(return_type='float') + else: + return return_element_type.format(return_type='numeric') + except ValueError: # Not a numeric if float conversion didn't work + pass + # If names are not equal, don't contain "." or are a builtin + if ({'self', 'cls', 'None'}.isdisjoint(return_vals_group) and all_eq + and all(['.' not in return_val + for return_val in return_vals_group])): + return return_element_name.format(return_name=return_vals_group[0]) + return placeholder + + def _generate_docstring_return_section(self, return_vals, header, + return_element_name, + return_element_type, + placeholder, indent): + """Generate the Returns section of a function/method docstring.""" + # If all return values are None, return none + non_none_vals = [return_val for return_val in return_vals + if return_val and return_val != 'None'] + if not non_none_vals: + return header + indent + 'None.' + + # Get only values with matching brackets that can be cleaned up + non_none_vals = [return_val.strip(' ()\t\n').rstrip(',') + for return_val in non_none_vals] + non_none_vals = [re.sub('([\"\'])(?:(?=(\\\\?))\\2.)*?\\1', + '"string"', return_val) + for return_val in non_none_vals] + unambiguous_vals = [] + for return_val in non_none_vals: + try: + cleaned_val = self.find_top_level_bracket_locations(return_val) + except IndexError: + continue + unambiguous_vals.append(cleaned_val) + if not unambiguous_vals: + return header + placeholder + + # If remaining are a mix of tuples and not, return single placeholder + single_vals, tuple_vals = [], [] + for return_val in unambiguous_vals: + (tuple_vals.append(return_val) if ',' in return_val + else single_vals.append(return_val)) + if single_vals and tuple_vals: + return header + placeholder + + # If return values are tuples of different length, return a placeholder + if tuple_vals: + num_elements = [return_val.count(',') + 1 + for return_val in tuple_vals] + if num_elements.count(num_elements[0]) != len(num_elements): + return header + placeholder + num_elements = num_elements[0] + else: + num_elements = 1 + + # If all have the same len but some ambiguous return that placeholders + if len(unambiguous_vals) != len(non_none_vals): + return header + '\n'.join( + [placeholder for __ in range(num_elements)]) + + # Handle tuple (or single) values position by position + return_vals_grouped = zip(*[ + [return_element.strip() for return_element in + return_val.split(',')] + for return_val in unambiguous_vals]) + return_elements_out = [] + for return_vals_group in return_vals_grouped: + return_elements_out.append( + self.parse_return_elements(return_vals_group, + return_element_name, + return_element_type, + placeholder)) + + return header + '\n'.join(return_elements_out) + + +class FunctionInfo(object): + """Parse function definition text.""" + + def __init__(self): + """.""" + self.has_info = False + self.func_text = '' + self.args_text = '' + self.func_indent = '' + self.arg_name_list = [] + self.arg_type_list = [] + self.arg_value_list = [] + self.return_type_annotated = None + self.return_value_in_body = [] + self.raise_list = None + self.has_yield = False + + @staticmethod + def is_char_in_pairs(pos_char, pairs): + """Return True if the character is in pairs of brackets or quotes.""" + for pos_left, pos_right in pairs.items(): + if pos_left < pos_char < pos_right: + return True + + return False + + @staticmethod + def _find_quote_position(text): + """Return the start and end position of pairs of quotes.""" + pos = {} + is_found_left_quote = False + + for idx, character in enumerate(text): + if is_found_left_quote is False: + if character == "'" or character == '"': + is_found_left_quote = True + quote = character + left_pos = idx + else: + if character == quote and text[idx - 1] != '\\': + pos[left_pos] = idx + is_found_left_quote = False + + if is_found_left_quote: + raise IndexError("No matching close quote at: " + str(left_pos)) + + return pos + + def _find_bracket_position(self, text, bracket_left, bracket_right, + pos_quote): + """Return the start and end position of pairs of brackets. + + https://stackoverflow.com/questions/29991917/ + indices-of-matching-parentheses-in-python + """ + pos = {} + pstack = [] + + for idx, character in enumerate(text): + if character == bracket_left and \ + not self.is_char_in_pairs(idx, pos_quote): + pstack.append(idx) + elif character == bracket_right and \ + not self.is_char_in_pairs(idx, pos_quote): + if len(pstack) == 0: + raise IndexError( + "No matching closing parens at: " + str(idx)) + pos[pstack.pop()] = idx + + if len(pstack) > 0: + raise IndexError( + "No matching opening parens at: " + str(pstack.pop())) + + return pos + + def split_arg_to_name_type_value(self, args_list): + """Split argument text to name, type, value.""" + for arg in args_list: + arg_type = None + arg_value = None + + has_type = False + has_value = False + + pos_colon = arg.find(':') + pos_equal = arg.find('=') + + if pos_equal > -1: + has_value = True + + if pos_colon > -1: + if not has_value: + has_type = True + elif pos_equal > pos_colon: # exception for def foo(arg1=":") + has_type = True + + if has_value and has_type: + arg_name = arg[0:pos_colon].strip() + arg_type = arg[pos_colon + 1:pos_equal].strip() + arg_value = arg[pos_equal + 1:].strip() + elif not has_value and has_type: + arg_name = arg[0:pos_colon].strip() + arg_type = arg[pos_colon + 1:].strip() + elif has_value and not has_type: + arg_name = arg[0:pos_equal].strip() + arg_value = arg[pos_equal + 1:].strip() + else: + arg_name = arg.strip() + + self.arg_name_list.append(arg_name) + self.arg_type_list.append(arg_type) + self.arg_value_list.append(arg_value) + + def split_args_text_to_list(self, args_text): + """Split the text including multiple arguments to list. + + This function uses a comma to separate arguments and ignores a comma in + brackets and quotes. + """ + args_list = [] + idx_find_start = 0 + idx_arg_start = 0 + + try: + pos_quote = self._find_quote_position(args_text) + pos_round = self._find_bracket_position(args_text, '(', ')', + pos_quote) + pos_curly = self._find_bracket_position(args_text, '{', '}', + pos_quote) + pos_square = self._find_bracket_position(args_text, '[', ']', + pos_quote) + except IndexError: + return None + + while True: + pos_comma = args_text.find(',', idx_find_start) + + if pos_comma == -1: + break + + idx_find_start = pos_comma + 1 + + if self.is_char_in_pairs(pos_comma, pos_round) or \ + self.is_char_in_pairs(pos_comma, pos_curly) or \ + self.is_char_in_pairs(pos_comma, pos_square) or \ + self.is_char_in_pairs(pos_comma, pos_quote): + continue + + args_list.append(args_text[idx_arg_start:pos_comma]) + idx_arg_start = pos_comma + 1 + + if idx_arg_start < len(args_text): + args_list.append(args_text[idx_arg_start:]) + + return args_list + + def parse_def(self, text): + """Parse the function definition text.""" + self.__init__() + + if not is_start_of_function(text): + return + + self.func_indent = get_indent(text) + + text = text.strip() + + return_type_re = re.search( + r'->[ ]*([\"\'a-zA-Z0-9_,()\[\] ]*):$', text) + if return_type_re: + self.return_type_annotated = return_type_re.group(1).strip(" ()\\") + if is_tuple_strings(self.return_type_annotated): + self.return_type_annotated = ( + "(" + self.return_type_annotated + ")" + ) + text_end = text.rfind(return_type_re.group(0)) + else: + self.return_type_annotated = None + text_end = len(text) + + pos_args_start = text.find('(') + 1 + pos_args_end = text.rfind(')', pos_args_start, text_end) + + self.args_text = text[pos_args_start:pos_args_end] + + args_list = self.split_args_text_to_list(self.args_text) + if args_list is not None: + self.has_info = True + self.split_arg_to_name_type_value(args_list) + + def parse_body(self, text): + """Parse the function body text.""" + re_raise = re.findall(r'[ \t]raise ([a-zA-Z0-9_]*)', text) + if len(re_raise) > 0: + self.raise_list = [x.strip() for x in re_raise] + # remove duplicates from list while keeping it in the order + # in python 2.7 + # stackoverflow.com/questions/7961363/removing-duplicates-in-lists + self.raise_list = list(OrderedDict.fromkeys(self.raise_list)) + + re_yield = re.search(r'[ \t]yield ', text) + if re_yield: + self.has_yield = True + + # get return value + pattern_return = r'return |yield ' + line_list = text.split('\n') + is_found_return = False + line_return_tmp = '' + + for line in line_list: + line = line.strip() + + if is_found_return is False: + if re.match(pattern_return, line): + is_found_return = True + + if is_found_return: + line_return_tmp += line + # check the integrity of line + try: + pos_quote = self._find_quote_position(line_return_tmp) + + if line_return_tmp[-1] == '\\': + line_return_tmp = line_return_tmp[:-1] + continue + + self._find_bracket_position(line_return_tmp, '(', ')', + pos_quote) + self._find_bracket_position(line_return_tmp, '{', '}', + pos_quote) + self._find_bracket_position(line_return_tmp, '[', ']', + pos_quote) + except IndexError: + continue + + return_value = re.sub(pattern_return, '', line_return_tmp) + self.return_value_in_body.append(return_value) + + is_found_return = False + line_return_tmp = '' + + +class QMenuOnlyForEnter(QMenu): + """The class executes the selected action when "enter key" is input. + + If a input of keyboard is not the "enter key", the menu is closed and + the input is inserted to code editor. + """ + + def __init__(self, code_editor): + """Init QMenu.""" + super(QMenuOnlyForEnter, self).__init__(code_editor) + self.code_editor = code_editor + + def keyPressEvent(self, event): + """Close the instance if key is not enter key.""" + key = event.key() + if key not in (Qt.Key_Enter, Qt.Key_Return): + self.code_editor.keyPressEvent(event) + self.close() + else: + super(QMenuOnlyForEnter, self).keyPressEvent(event) diff --git a/spyder/plugins/editor/plugin.py b/spyder/plugins/editor/plugin.py index ffb8d3cf72f..6977f10d0e5 100644 --- a/spyder/plugins/editor/plugin.py +++ b/spyder/plugins/editor/plugin.py @@ -1,3584 +1,3584 @@ -# -*- coding: utf-8 -*- -# -# Copyright © Spyder Project Contributors -# Licensed under the terms of the MIT License -# (see spyder/__init__.py for details) - -"""Editor Plugin""" - -# pylint: disable=C0103 -# pylint: disable=R0903 -# pylint: disable=R0911 -# pylint: disable=R0201 - -# Standard library imports -import logging -import os -import os.path as osp -import re -import sys -import time - -# Third party imports -from qtpy.compat import from_qvariant, getopenfilenames, to_qvariant -from qtpy.QtCore import QByteArray, Qt, Signal, Slot, QDir -from qtpy.QtGui import QTextCursor -from qtpy.QtPrintSupport import (QAbstractPrintDialog, QPrintDialog, QPrinter, - QPrintPreviewDialog) -from qtpy.QtWidgets import (QAction, QActionGroup, QApplication, QDialog, - QFileDialog, QInputDialog, QMenu, QSplitter, - QToolBar, QVBoxLayout, QWidget) - -# Local imports -from spyder.api.config.decorators import on_conf_change -from spyder.api.config.mixins import SpyderConfigurationObserver -from spyder.api.panel import Panel -from spyder.api.plugins import Plugins, SpyderPluginWidget -from spyder.config.base import _, get_conf_path, running_under_pytest -from spyder.config.manager import CONF -from spyder.config.utils import (get_edit_filetypes, get_edit_filters, - get_filter) -from spyder.py3compat import PY2, qbytearray_to_str, to_text_string -from spyder.utils import encoding, programs, sourcecode -from spyder.utils.icon_manager import ima -from spyder.utils.qthelpers import create_action, add_actions, MENU_SEPARATOR -from spyder.utils.misc import getcwd_or_home -from spyder.widgets.findreplace import FindReplace -from spyder.plugins.editor.confpage import EditorConfigPage -from spyder.plugins.editor.utils.autosave import AutosaveForPlugin -from spyder.plugins.editor.utils.switcher import EditorSwitcherManager -from spyder.plugins.editor.widgets.codeeditor_widgets import Printer -from spyder.plugins.editor.widgets.editor import (EditorMainWindow, - EditorSplitter, - EditorStack,) -from spyder.plugins.editor.widgets.codeeditor import CodeEditor -from spyder.plugins.editor.utils.bookmarks import (load_bookmarks, - save_bookmarks) -from spyder.plugins.editor.utils.debugger import (clear_all_breakpoints, - clear_breakpoint) -from spyder.plugins.editor.widgets.status import (CursorPositionStatus, - EncodingStatus, EOLStatus, - ReadWriteStatus, VCSStatus) -from spyder.plugins.run.widgets import (ALWAYS_OPEN_FIRST_RUN_OPTION, - get_run_configuration, RunConfigDialog, - RunConfiguration, RunConfigOneDialog) -from spyder.plugins.mainmenu.api import ApplicationMenus -from spyder.widgets.simplecodeeditor import SimpleCodeEditor - - -logger = logging.getLogger(__name__) - - -class Editor(SpyderPluginWidget, SpyderConfigurationObserver): - """ - Multi-file Editor widget - """ - CONF_SECTION = 'editor' - CONFIGWIDGET_CLASS = EditorConfigPage - CONF_FILE = False - TEMPFILE_PATH = get_conf_path('temp.py') - TEMPLATE_PATH = get_conf_path('template.py') - DISABLE_ACTIONS_WHEN_HIDDEN = False # SpyderPluginWidget class attribute - - # This is required for the new API - NAME = 'editor' - REQUIRES = [Plugins.Console] - OPTIONAL = [Plugins.Completions, Plugins.OutlineExplorer] - - # Signals - run_in_current_ipyclient = Signal(str, str, str, - bool, bool, bool, bool, bool) - run_cell_in_ipyclient = Signal(str, object, str, bool, bool) - debug_cell_in_ipyclient = Signal(str, object, str, bool, bool) - exec_in_extconsole = Signal(str, bool) - redirect_stdio = Signal(bool) - - sig_dir_opened = Signal(str) - """ - This signal is emitted when the editor changes the current directory. - - Parameters - ---------- - new_working_directory: str - The new working directory path. - - Notes - ----- - This option is available on the options menu of the editor plugin - """ - - breakpoints_saved = Signal() - - sig_file_opened_closed_or_updated = Signal(str, str) - """ - This signal is emitted when a file is opened, closed or updated, - including switching among files. - - Parameters - ---------- - filename: str - Name of the file that was opened, closed or updated. - language: str - Name of the programming language of the file that was opened, - closed or updated. - """ - - sig_file_debug_message_requested = Signal() - - # This signal is fired for any focus change among all editor stacks - sig_editor_focus_changed = Signal() - - sig_help_requested = Signal(dict) - """ - This signal is emitted to request help on a given object `name`. - - Parameters - ---------- - help_data: dict - Dictionary required by the Help pane to render a docstring. - - Examples - -------- - >>> help_data = { - 'obj_text': str, - 'name': str, - 'argspec': str, - 'note': str, - 'docstring': str, - 'force_refresh': bool, - 'path': str, - } - - See Also - -------- - :py:meth:spyder.plugins.editor.widgets.editor.EditorStack.send_to_help - """ - - sig_open_files_finished = Signal() - """ - This signal is emitted when the editor finished to open files. - """ - - def __init__(self, parent, ignore_last_opened_files=False): - SpyderPluginWidget.__init__(self, parent) - - self.__set_eol_chars = True - - # Creating template if it doesn't already exist - if not osp.isfile(self.TEMPLATE_PATH): - if os.name == "nt": - shebang = [] - else: - shebang = ['#!/usr/bin/env python' + ('2' if PY2 else '3')] - header = shebang + [ - '# -*- coding: utf-8 -*-', - '"""', 'Created on %(date)s', '', - '@author: %(username)s', '"""', '', ''] - try: - encoding.write(os.linesep.join(header), self.TEMPLATE_PATH, - 'utf-8') - except EnvironmentError: - pass - - self.projects = None - self.outlineexplorer = None - - self.file_dependent_actions = [] - self.pythonfile_dependent_actions = [] - self.dock_toolbar_actions = None - self.edit_menu_actions = None #XXX: find another way to notify Spyder - self.stack_menu_actions = None - self.checkable_actions = {} - - self.__first_open_files_setup = True - self.editorstacks = [] - self.last_focused_editorstack = {} - self.editorwindows = [] - self.editorwindows_to_be_created = [] - self.toolbar_list = None - self.menu_list = None - - # We need to call this here to create self.dock_toolbar_actions, - # which is used below. - self._setup() - self.options_button.hide() - - # Configuration dialog size - self.dialog_size = None - - self.vcs_status = VCSStatus(self) - self.cursorpos_status = CursorPositionStatus(self) - self.encoding_status = EncodingStatus(self) - self.eol_status = EOLStatus(self) - self.readwrite_status = ReadWriteStatus(self) - - # TODO: temporal fix while editor uses new API - statusbar = self.main.get_plugin(Plugins.StatusBar, error=False) - if statusbar: - statusbar.add_status_widget(self.readwrite_status) - statusbar.add_status_widget(self.eol_status) - statusbar.add_status_widget(self.encoding_status) - statusbar.add_status_widget(self.cursorpos_status) - statusbar.add_status_widget(self.vcs_status) - - layout = QVBoxLayout() - self.dock_toolbar = QToolBar(self) - add_actions(self.dock_toolbar, self.dock_toolbar_actions) - layout.addWidget(self.dock_toolbar) - - self.last_edit_cursor_pos = None - self.cursor_undo_history = [] - self.cursor_redo_history = [] - self.__ignore_cursor_history = True - - # Completions setup - self.completion_capabilities = {} - - # Setup new windows: - self.main.all_actions_defined.connect(self.setup_other_windows) - - # Change module completions when PYTHONPATH changes - self.main.sig_pythonpath_changed.connect(self.set_path) - - # Find widget - self.find_widget = FindReplace(self, enable_replace=True) - self.find_widget.hide() - self.register_widget_shortcuts(self.find_widget) - - # Start autosave component - # (needs to be done before EditorSplitter) - self.autosave = AutosaveForPlugin(self) - self.autosave.try_recover_from_autosave() - - # Multiply by 1000 to convert seconds to milliseconds - self.autosave.interval = self.get_option('autosave_interval') * 1000 - self.autosave.enabled = self.get_option('autosave_enabled') - - # SimpleCodeEditor instance used to print file contents - self._print_editor = self._create_print_editor() - self._print_editor.hide() - - # Tabbed editor widget + Find/Replace widget - editor_widgets = QWidget(self) - editor_layout = QVBoxLayout() - editor_layout.setContentsMargins(0, 0, 0, 0) - editor_widgets.setLayout(editor_layout) - self.editorsplitter = EditorSplitter(self, self, - self.stack_menu_actions, first=True) - editor_layout.addWidget(self.editorsplitter) - editor_layout.addWidget(self.find_widget) - editor_layout.addWidget(self._print_editor) - - # Splitter: editor widgets (see above) + outline explorer - self.splitter = QSplitter(self) - self.splitter.setContentsMargins(0, 0, 0, 0) - self.splitter.addWidget(editor_widgets) - self.splitter.setStretchFactor(0, 5) - self.splitter.setStretchFactor(1, 1) - layout.addWidget(self.splitter) - self.setLayout(layout) - self.setFocusPolicy(Qt.ClickFocus) - - # Editor's splitter state - state = self.get_option('splitter_state', None) - if state is not None: - self.splitter.restoreState( QByteArray().fromHex( - str(state).encode('utf-8')) ) - - self.recent_files = self.get_option('recent_files', []) - self.untitled_num = 0 - - # Parameters of last file execution: - self.__last_ic_exec = None # internal console - self.__last_ec_exec = None # external console - - # File types and filters used by the Open dialog - self.edit_filetypes = None - self.edit_filters = None - - self.__ignore_cursor_history = False - current_editor = self.get_current_editor() - if current_editor is not None: - filename = self.get_current_filename() - cursor = current_editor.textCursor() - self.add_cursor_to_history(filename, cursor) - self.update_cursorpos_actions() - self.set_path() - - def set_projects(self, projects): - self.projects = projects - - @Slot() - def show_hide_projects(self): - if self.projects is not None: - dw = self.projects.dockwidget - if dw.isVisible(): - dw.hide() - else: - dw.show() - dw.raise_() - self.switch_to_plugin() - - def set_outlineexplorer(self, outlineexplorer): - self.outlineexplorer = outlineexplorer - for editorstack in self.editorstacks: - # Pass the OutlineExplorer widget to the stacks because they - # don't need the plugin - editorstack.set_outlineexplorer(self.outlineexplorer.get_widget()) - self.outlineexplorer.get_widget().edit_goto.connect( - lambda filenames, goto, word: - self.load(filenames=filenames, goto=goto, word=word, - editorwindow=self)) - self.outlineexplorer.get_widget().edit.connect( - lambda filenames: - self.load(filenames=filenames, editorwindow=self)) - - #------ Private API -------------------------------------------------------- - def restore_scrollbar_position(self): - """Restoring scrollbar position after main window is visible""" - # Widget is now visible, we may center cursor on top level editor: - try: - self.get_current_editor().centerCursor() - except AttributeError: - pass - - @Slot(dict) - def report_open_file(self, options): - """Report that a file was opened to the completion manager.""" - filename = options['filename'] - language = options['language'] - codeeditor = options['codeeditor'] - status = None - if self.main.get_plugin(Plugins.Completions, error=False): - status = ( - self.main.completions.start_completion_services_for_language( - language.lower())) - self.main.completions.register_file( - language.lower(), filename, codeeditor) - if status: - if language.lower() in self.completion_capabilities: - # When this condition is True, it means there's a server - # that can provide completion services for this file. - codeeditor.register_completion_capabilities( - self.completion_capabilities[language.lower()]) - codeeditor.start_completion_services() - elif self.main.completions.is_fallback_only(language.lower()): - # This is required to use fallback completions for files - # without a language server. - codeeditor.start_completion_services() - else: - if codeeditor.language == language.lower(): - logger.debug('Setting {0} completions off'.format(filename)) - codeeditor.completions_available = False - - @Slot(dict, str) - def register_completion_capabilities(self, capabilities, language): - """ - Register completion server capabilities in all editorstacks. - - Parameters - ---------- - capabilities: dict - Capabilities supported by a language server. - language: str - Programming language for the language server (it has to be - in small caps). - """ - logger.debug( - 'Completion server capabilities for {!s} are: {!r}'.format( - language, capabilities) - ) - - # This is required to start workspace before completion - # services when Spyder starts with an open project. - # TODO: Find a better solution for it in the future!! - projects = self.main.get_plugin(Plugins.Projects, error=False) - if projects: - projects.start_workspace_services() - - self.completion_capabilities[language] = dict(capabilities) - for editorstack in self.editorstacks: - editorstack.register_completion_capabilities( - capabilities, language) - - self.start_completion_services(language) - - def start_completion_services(self, language): - """Notify all editorstacks about LSP server availability.""" - for editorstack in self.editorstacks: - editorstack.start_completion_services(language) - - def stop_completion_services(self, language): - """Notify all editorstacks about LSP server unavailability.""" - for editorstack in self.editorstacks: - editorstack.stop_completion_services(language) - - def send_completion_request(self, language, request, params): - logger.debug("Perform request {0} for: {1}".format( - request, params['file'])) - try: - self.main.completions.send_request(language, request, params) - except AttributeError: - # Completions was closed - pass - - @Slot(str, tuple, dict) - def _rpc_call(self, method, args, kwargs): - meth = getattr(self, method) - meth(*args, **kwargs) - - #------ SpyderPluginWidget API --------------------------------------------- - @staticmethod - def get_plugin_title(): - """Return widget title""" - # TODO: This is a temporary measure to get the title of this plugin - # without creating an instance - title = _('Editor') - return title - - def get_plugin_icon(self): - """Return widget icon.""" - return ima.icon('edit') - - def get_focus_widget(self): - """ - Return the widget to give focus to. - - This happens when plugin's dockwidget is raised on top-level. - """ - return self.get_current_editor() - - def _visibility_changed(self, enable): - """DockWidget visibility has changed""" - SpyderPluginWidget._visibility_changed(self, enable) - if self.dockwidget is None: - return - if self.dockwidget.isWindow(): - self.dock_toolbar.show() - else: - self.dock_toolbar.hide() - if enable: - self.refresh_plugin() - self.sig_update_plugin_title.emit() - - def refresh_plugin(self): - """Refresh editor plugin""" - editorstack = self.get_current_editorstack() - editorstack.refresh() - self.refresh_save_all_action() - - def closing_plugin(self, cancelable=False): - """Perform actions before parent main window is closed""" - state = self.splitter.saveState() - self.set_option('splitter_state', qbytearray_to_str(state)) - editorstack = self.editorstacks[0] - - active_project_path = None - if self.projects is not None: - active_project_path = self.projects.get_active_project_path() - if not active_project_path: - self.set_open_filenames() - else: - self.projects.set_project_filenames( - [finfo.filename for finfo in editorstack.data]) - - self.set_option('layout_settings', - self.editorsplitter.get_layout_settings()) - self.set_option('windows_layout_settings', - [win.get_layout_settings() for win in self.editorwindows]) -# self.set_option('filenames', filenames) - self.set_option('recent_files', self.recent_files) - - # Stop autosave timer before closing windows - self.autosave.stop_autosave_timer() - - try: - if not editorstack.save_if_changed(cancelable) and cancelable: - return False - else: - for win in self.editorwindows[:]: - win.close() - return True - except IndexError: - return True - - def get_plugin_actions(self): - """Return a list of actions related to plugin""" - # ---- File menu and toolbar ---- - self.new_action = create_action( - self, - _("&New file..."), - icon=ima.icon('filenew'), tip=_("New file"), - triggered=self.new, - context=Qt.WidgetShortcut - ) - self.register_shortcut(self.new_action, context="Editor", - name="New file", add_shortcut_to_tip=True) - - self.open_last_closed_action = create_action( - self, - _("O&pen last closed"), - tip=_("Open last closed"), - triggered=self.open_last_closed - ) - self.register_shortcut(self.open_last_closed_action, context="Editor", - name="Open last closed") - - self.open_action = create_action(self, _("&Open..."), - icon=ima.icon('fileopen'), tip=_("Open file"), - triggered=self.load, - context=Qt.WidgetShortcut) - self.register_shortcut(self.open_action, context="Editor", - name="Open file", add_shortcut_to_tip=True) - - self.revert_action = create_action(self, _("&Revert"), - icon=ima.icon('revert'), tip=_("Revert file from disk"), - triggered=self.revert) - - self.save_action = create_action(self, _("&Save"), - icon=ima.icon('filesave'), tip=_("Save file"), - triggered=self.save, - context=Qt.WidgetShortcut) - self.register_shortcut(self.save_action, context="Editor", - name="Save file", add_shortcut_to_tip=True) - - self.save_all_action = create_action(self, _("Sav&e all"), - icon=ima.icon('save_all'), tip=_("Save all files"), - triggered=self.save_all, - context=Qt.WidgetShortcut) - self.register_shortcut(self.save_all_action, context="Editor", - name="Save all", add_shortcut_to_tip=True) - - save_as_action = create_action(self, _("Save &as..."), None, - ima.icon('filesaveas'), tip=_("Save current file as..."), - triggered=self.save_as, - context=Qt.WidgetShortcut) - self.register_shortcut(save_as_action, "Editor", "Save As") - - save_copy_as_action = create_action(self, _("Save copy as..."), None, - ima.icon('filesaveas'), _("Save copy of current file as..."), - triggered=self.save_copy_as) - - print_preview_action = create_action(self, _("Print preview..."), - tip=_("Print preview..."), triggered=self.print_preview) - self.print_action = create_action(self, _("&Print..."), - icon=ima.icon('print'), tip=_("Print current file..."), - triggered=self.print_file) - # Shortcut for close_action is defined in widgets/editor.py - self.close_action = create_action(self, _("&Close"), - icon=ima.icon('fileclose'), tip=_("Close current file"), - triggered=self.close_file) - - self.close_all_action = create_action(self, _("C&lose all"), - icon=ima.icon('filecloseall'), tip=_("Close all opened files"), - triggered=self.close_all_files, - context=Qt.WidgetShortcut) - self.register_shortcut(self.close_all_action, context="Editor", - name="Close all") - - # ---- Find menu and toolbar ---- - _text = _("&Find text") - find_action = create_action(self, _text, icon=ima.icon('find'), - tip=_text, triggered=self.find, - context=Qt.WidgetShortcut) - self.register_shortcut(find_action, context="find_replace", - name="Find text", add_shortcut_to_tip=True) - find_next_action = create_action(self, _("Find &next"), - icon=ima.icon('findnext'), - triggered=self.find_next, - context=Qt.WidgetShortcut) - self.register_shortcut(find_next_action, context="find_replace", - name="Find next") - find_previous_action = create_action(self, _("Find &previous"), - icon=ima.icon('findprevious'), - triggered=self.find_previous, - context=Qt.WidgetShortcut) - self.register_shortcut(find_previous_action, context="find_replace", - name="Find previous") - _text = _("&Replace text") - replace_action = create_action(self, _text, icon=ima.icon('replace'), - tip=_text, triggered=self.replace, - context=Qt.WidgetShortcut) - self.register_shortcut(replace_action, context="find_replace", - name="Replace text") - - # ---- Debug menu and toolbar ---- - set_clear_breakpoint_action = create_action(self, - _("Set/Clear breakpoint"), - icon=ima.icon('breakpoint_big'), - triggered=self.set_or_clear_breakpoint, - context=Qt.WidgetShortcut) - self.register_shortcut(set_clear_breakpoint_action, context="Editor", - name="Breakpoint") - - set_cond_breakpoint_action = create_action(self, - _("Set/Edit conditional breakpoint"), - icon=ima.icon('breakpoint_cond_big'), - triggered=self.set_or_edit_conditional_breakpoint, - context=Qt.WidgetShortcut) - self.register_shortcut(set_cond_breakpoint_action, context="Editor", - name="Conditional breakpoint") - - clear_all_breakpoints_action = create_action(self, - _('Clear breakpoints in all files'), - triggered=self.clear_all_breakpoints) - - # --- Debug toolbar --- - self.debug_action = create_action( - self, _("&Debug"), - icon=ima.icon('debug'), - tip=_("Debug file"), - triggered=self.debug_file) - self.register_shortcut(self.debug_action, context="_", name="Debug", - add_shortcut_to_tip=True) - - self.debug_next_action = create_action( - self, _("Step"), - icon=ima.icon('arrow-step-over'), tip=_("Run current line"), - triggered=lambda: self.debug_command("next")) - self.register_shortcut(self.debug_next_action, "_", "Debug Step Over", - add_shortcut_to_tip=True) - - self.debug_continue_action = create_action( - self, _("Continue"), - icon=ima.icon('arrow-continue'), - tip=_("Continue execution until next breakpoint"), - triggered=lambda: self.debug_command("continue")) - self.register_shortcut( - self.debug_continue_action, "_", "Debug Continue", - add_shortcut_to_tip=True) - - self.debug_step_action = create_action( - self, _("Step Into"), - icon=ima.icon('arrow-step-in'), - tip=_("Step into function or method of current line"), - triggered=lambda: self.debug_command("step")) - self.register_shortcut(self.debug_step_action, "_", "Debug Step Into", - add_shortcut_to_tip=True) - - self.debug_return_action = create_action( - self, _("Step Return"), - icon=ima.icon('arrow-step-out'), - tip=_("Run until current function or method returns"), - triggered=lambda: self.debug_command("return")) - self.register_shortcut( - self.debug_return_action, "_", "Debug Step Return", - add_shortcut_to_tip=True) - - self.debug_exit_action = create_action( - self, _("Stop"), - icon=ima.icon('stop_debug'), tip=_("Stop debugging"), - triggered=self.stop_debugging) - self.register_shortcut(self.debug_exit_action, "_", "Debug Exit", - add_shortcut_to_tip=True) - - # --- Run toolbar --- - run_action = create_action(self, _("&Run"), icon=ima.icon('run'), - tip=_("Run file"), - triggered=self.run_file) - self.register_shortcut(run_action, context="_", name="Run", - add_shortcut_to_tip=True) - - configure_action = create_action( - self, - _("&Configuration per file..."), - icon=ima.icon('run_settings'), - tip=_("Run settings"), - menurole=QAction.NoRole, - triggered=self.edit_run_configurations) - - self.register_shortcut(configure_action, context="_", - name="Configure", add_shortcut_to_tip=True) - - re_run_action = create_action(self, _("Re-run &last script"), - icon=ima.icon('run_again'), - tip=_("Run again last file"), - triggered=self.re_run_file) - self.register_shortcut(re_run_action, context="_", - name="Re-run last script", - add_shortcut_to_tip=True) - - run_selected_action = create_action(self, _("Run &selection or " - "current line"), - icon=ima.icon('run_selection'), - tip=_("Run selection or " - "current line"), - triggered=self.run_selection, - context=Qt.WidgetShortcut) - self.register_shortcut(run_selected_action, context="Editor", - name="Run selection", add_shortcut_to_tip=True) - - run_to_line_action = create_action(self, _("Run &to current line"), - tip=_("Run to current line"), - triggered=self.run_to_line, - context=Qt.WidgetShortcut) - self.register_shortcut(run_to_line_action, context="Editor", - name="Run to line", add_shortcut_to_tip=True) - - run_from_line_action = create_action(self, _("Run &from current line"), - tip=_("Run from current line"), - triggered=self.run_from_line, - context=Qt.WidgetShortcut) - self.register_shortcut(run_from_line_action, context="Editor", - name="Run from line", add_shortcut_to_tip=True) - - run_cell_action = create_action(self, - _("Run cell"), - icon=ima.icon('run_cell'), - tip=_("Run current cell \n" - "[Use #%% to create cells]"), - triggered=self.run_cell, - context=Qt.WidgetShortcut) - - self.register_shortcut(run_cell_action, context="Editor", - name="Run cell", add_shortcut_to_tip=True) - - run_cell_advance_action = create_action( - self, - _("Run cell and advance"), - icon=ima.icon('run_cell_advance'), - tip=_("Run current cell and go to the next one "), - triggered=self.run_cell_and_advance, - context=Qt.WidgetShortcut) - - self.register_shortcut(run_cell_advance_action, context="Editor", - name="Run cell and advance", - add_shortcut_to_tip=True) - - self.debug_cell_action = create_action( - self, - _("Debug cell"), - icon=ima.icon('debug_cell'), - tip=_("Debug current cell " - "(Alt+Shift+Enter)"), - triggered=self.debug_cell, - context=Qt.WidgetShortcut) - - self.register_shortcut(self.debug_cell_action, context="Editor", - name="Debug cell", - add_shortcut_to_tip=True) - - re_run_last_cell_action = create_action(self, - _("Re-run last cell"), - tip=_("Re run last cell "), - triggered=self.re_run_last_cell, - context=Qt.WidgetShortcut) - self.register_shortcut(re_run_last_cell_action, - context="Editor", - name='re-run last cell', - add_shortcut_to_tip=True) - - # --- Source code Toolbar --- - self.todo_list_action = create_action(self, - _("Show todo list"), icon=ima.icon('todo_list'), - tip=_("Show comments list (TODO/FIXME/XXX/HINT/TIP/@todo/" - "HACK/BUG/OPTIMIZE/!!!/???)"), - triggered=self.go_to_next_todo) - self.todo_menu = QMenu(self) - self.todo_menu.setStyleSheet("QMenu {menu-scrollable: 1;}") - self.todo_list_action.setMenu(self.todo_menu) - self.todo_menu.aboutToShow.connect(self.update_todo_menu) - - self.warning_list_action = create_action(self, - _("Show warning/error list"), icon=ima.icon('wng_list'), - tip=_("Show code analysis warnings/errors"), - triggered=self.go_to_next_warning) - self.warning_menu = QMenu(self) - self.warning_menu.setStyleSheet("QMenu {menu-scrollable: 1;}") - self.warning_list_action.setMenu(self.warning_menu) - self.warning_menu.aboutToShow.connect(self.update_warning_menu) - self.previous_warning_action = create_action(self, - _("Previous warning/error"), icon=ima.icon('prev_wng'), - tip=_("Go to previous code analysis warning/error"), - triggered=self.go_to_previous_warning, - context=Qt.WidgetShortcut) - self.register_shortcut(self.previous_warning_action, - context="Editor", - name="Previous warning", - add_shortcut_to_tip=True) - self.next_warning_action = create_action(self, - _("Next warning/error"), icon=ima.icon('next_wng'), - tip=_("Go to next code analysis warning/error"), - triggered=self.go_to_next_warning, - context=Qt.WidgetShortcut) - self.register_shortcut(self.next_warning_action, - context="Editor", - name="Next warning", - add_shortcut_to_tip=True) - - self.previous_edit_cursor_action = create_action(self, - _("Last edit location"), icon=ima.icon('last_edit_location'), - tip=_("Go to last edit location"), - triggered=self.go_to_last_edit_location, - context=Qt.WidgetShortcut) - self.register_shortcut(self.previous_edit_cursor_action, - context="Editor", - name="Last edit location", - add_shortcut_to_tip=True) - self.previous_cursor_action = create_action(self, - _("Previous cursor position"), icon=ima.icon('prev_cursor'), - tip=_("Go to previous cursor position"), - triggered=self.go_to_previous_cursor_position, - context=Qt.WidgetShortcut) - self.register_shortcut(self.previous_cursor_action, - context="Editor", - name="Previous cursor position", - add_shortcut_to_tip=True) - self.next_cursor_action = create_action(self, - _("Next cursor position"), icon=ima.icon('next_cursor'), - tip=_("Go to next cursor position"), - triggered=self.go_to_next_cursor_position, - context=Qt.WidgetShortcut) - self.register_shortcut(self.next_cursor_action, - context="Editor", - name="Next cursor position", - add_shortcut_to_tip=True) - - # --- Edit Toolbar --- - self.toggle_comment_action = create_action(self, - _("Comment")+"/"+_("Uncomment"), icon=ima.icon('comment'), - tip=_("Comment current line or selection"), - triggered=self.toggle_comment, context=Qt.WidgetShortcut) - self.register_shortcut(self.toggle_comment_action, context="Editor", - name="Toggle comment") - blockcomment_action = create_action(self, _("Add &block comment"), - tip=_("Add block comment around " - "current line or selection"), - triggered=self.blockcomment, context=Qt.WidgetShortcut) - self.register_shortcut(blockcomment_action, context="Editor", - name="Blockcomment") - unblockcomment_action = create_action(self, - _("R&emove block comment"), - tip = _("Remove comment block around " - "current line or selection"), - triggered=self.unblockcomment, context=Qt.WidgetShortcut) - self.register_shortcut(unblockcomment_action, context="Editor", - name="Unblockcomment") - - # ---------------------------------------------------------------------- - # The following action shortcuts are hard-coded in CodeEditor - # keyPressEvent handler (the shortcut is here only to inform user): - # (context=Qt.WidgetShortcut -> disable shortcut for other widgets) - self.indent_action = create_action(self, - _("Indent"), "Tab", icon=ima.icon('indent'), - tip=_("Indent current line or selection"), - triggered=self.indent, context=Qt.WidgetShortcut) - self.unindent_action = create_action(self, - _("Unindent"), "Shift+Tab", icon=ima.icon('unindent'), - tip=_("Unindent current line or selection"), - triggered=self.unindent, context=Qt.WidgetShortcut) - - self.text_uppercase_action = create_action(self, - _("Toggle Uppercase"), icon=ima.icon('toggle_uppercase'), - tip=_("Change to uppercase current line or selection"), - triggered=self.text_uppercase, context=Qt.WidgetShortcut) - self.register_shortcut(self.text_uppercase_action, context="Editor", - name="transform to uppercase") - - self.text_lowercase_action = create_action(self, - _("Toggle Lowercase"), icon=ima.icon('toggle_lowercase'), - tip=_("Change to lowercase current line or selection"), - triggered=self.text_lowercase, context=Qt.WidgetShortcut) - self.register_shortcut(self.text_lowercase_action, context="Editor", - name="transform to lowercase") - # ---------------------------------------------------------------------- - - self.win_eol_action = create_action( - self, - _("CRLF (Windows)"), - toggled=lambda checked: self.toggle_eol_chars('nt', checked) - ) - self.linux_eol_action = create_action( - self, - _("LF (Unix)"), - toggled=lambda checked: self.toggle_eol_chars('posix', checked) - ) - self.mac_eol_action = create_action( - self, - _("CR (macOS)"), - toggled=lambda checked: self.toggle_eol_chars('mac', checked) - ) - eol_action_group = QActionGroup(self) - eol_actions = (self.win_eol_action, self.linux_eol_action, - self.mac_eol_action) - add_actions(eol_action_group, eol_actions) - eol_menu = QMenu(_("Convert end-of-line characters"), self) - eol_menu.setObjectName('checkbox-padding') - add_actions(eol_menu, eol_actions) - - trailingspaces_action = create_action( - self, - _("Remove trailing spaces"), - triggered=self.remove_trailing_spaces) - - formatter = CONF.get( - 'completions', - ('provider_configuration', 'lsp', 'values', 'formatting'), - '') - self.formatting_action = create_action( - self, - _('Format file or selection with {0}').format( - formatter.capitalize()), - shortcut=CONF.get_shortcut('editor', 'autoformatting'), - context=Qt.WidgetShortcut, - triggered=self.format_document_or_selection) - self.formatting_action.setEnabled(False) - - # Checkable actions - showblanks_action = self._create_checkable_action( - _("Show blank spaces"), 'blank_spaces', 'set_blanks_enabled') - - scrollpastend_action = self._create_checkable_action( - _("Scroll past the end"), 'scroll_past_end', - 'set_scrollpastend_enabled') - - showindentguides_action = self._create_checkable_action( - _("Show indent guides"), 'indent_guides', 'set_indent_guides') - - showcodefolding_action = self._create_checkable_action( - _("Show code folding"), 'code_folding', 'set_code_folding_enabled') - - show_classfunc_dropdown_action = self._create_checkable_action( - _("Show selector for classes and functions"), - 'show_class_func_dropdown', 'set_classfunc_dropdown_visible') - - show_codestyle_warnings_action = self._create_checkable_action( - _("Show code style warnings"), 'pycodestyle',) - - show_docstring_warnings_action = self._create_checkable_action( - _("Show docstring style warnings"), 'pydocstyle') - - underline_errors = self._create_checkable_action( - _("Underline errors and warnings"), - 'underline_errors', 'set_underline_errors_enabled') - - self.checkable_actions = { - 'blank_spaces': showblanks_action, - 'scroll_past_end': scrollpastend_action, - 'indent_guides': showindentguides_action, - 'code_folding': showcodefolding_action, - 'show_class_func_dropdown': show_classfunc_dropdown_action, - 'pycodestyle': show_codestyle_warnings_action, - 'pydocstyle': show_docstring_warnings_action, - 'underline_errors': underline_errors} - - fixindentation_action = create_action(self, _("Fix indentation"), - tip=_("Replace tab characters by space characters"), - triggered=self.fix_indentation) - - gotoline_action = create_action(self, _("Go to line..."), - icon=ima.icon('gotoline'), - triggered=self.go_to_line, - context=Qt.WidgetShortcut) - self.register_shortcut(gotoline_action, context="Editor", - name="Go to line") - - workdir_action = create_action(self, - _("Set console working directory"), - icon=ima.icon('DirOpenIcon'), - tip=_("Set current console (and file explorer) working " - "directory to current script directory"), - triggered=self.__set_workdir) - - self.max_recent_action = create_action(self, - _("Maximum number of recent files..."), - triggered=self.change_max_recent_files) - self.clear_recent_action = create_action(self, - _("Clear this list"), tip=_("Clear recent files list"), - triggered=self.clear_recent_files) - - # Fixes spyder-ide/spyder#6055. - # See: https://bugreports.qt.io/browse/QTBUG-8596 - self.tab_navigation_actions = [] - if sys.platform == 'darwin': - self.go_to_next_file_action = create_action( - self, - _("Go to next file"), - shortcut=CONF.get_shortcut('editor', 'go to previous file'), - triggered=self.go_to_next_file, - ) - self.go_to_previous_file_action = create_action( - self, - _("Go to previous file"), - shortcut=CONF.get_shortcut('editor', 'go to next file'), - triggered=self.go_to_previous_file, - ) - self.register_shortcut( - self.go_to_next_file_action, - context="Editor", - name="Go to next file", - ) - self.register_shortcut( - self.go_to_previous_file_action, - context="Editor", - name="Go to previous file", - ) - self.tab_navigation_actions = [ - MENU_SEPARATOR, - self.go_to_previous_file_action, - self.go_to_next_file_action, - ] - - # ---- File menu/toolbar construction ---- - self.recent_file_menu = QMenu(_("Open &recent"), self) - self.recent_file_menu.aboutToShow.connect(self.update_recent_file_menu) - - from spyder.plugins.mainmenu.api import ( - ApplicationMenus, FileMenuSections) - # New Section - self.main.mainmenu.add_item_to_application_menu( - self.new_action, - menu_id=ApplicationMenus.File, - section=FileMenuSections.New, - before_section=FileMenuSections.Restart, - omit_id=True) - # Open section - open_actions = [ - self.open_action, - self.open_last_closed_action, - self.recent_file_menu, - ] - for open_action in open_actions: - self.main.mainmenu.add_item_to_application_menu( - open_action, - menu_id=ApplicationMenus.File, - section=FileMenuSections.Open, - before_section=FileMenuSections.Restart, - omit_id=True) - # Save section - save_actions = [ - self.save_action, - self.save_all_action, - save_as_action, - save_copy_as_action, - self.revert_action, - ] - for save_action in save_actions: - self.main.mainmenu.add_item_to_application_menu( - save_action, - menu_id=ApplicationMenus.File, - section=FileMenuSections.Save, - before_section=FileMenuSections.Restart, - omit_id=True) - # Print - print_actions = [ - print_preview_action, - self.print_action, - ] - for print_action in print_actions: - self.main.mainmenu.add_item_to_application_menu( - print_action, - menu_id=ApplicationMenus.File, - section=FileMenuSections.Print, - before_section=FileMenuSections.Restart, - omit_id=True) - # Close - close_actions = [ - self.close_action, - self.close_all_action - ] - for close_action in close_actions: - self.main.mainmenu.add_item_to_application_menu( - close_action, - menu_id=ApplicationMenus.File, - section=FileMenuSections.Close, - before_section=FileMenuSections.Restart, - omit_id=True) - # Navigation - if sys.platform == 'darwin': - self.main.mainmenu.add_item_to_application_menu( - self.tab_navigation_actions, - menu_id=ApplicationMenus.File, - section=FileMenuSections.Navigation, - before_section=FileMenuSections.Restart, - omit_id=True) - - file_toolbar_actions = ([self.new_action, self.open_action, - self.save_action, self.save_all_action] + - self.main.file_toolbar_actions) - - self.main.file_toolbar_actions += file_toolbar_actions - - # ---- Find menu/toolbar construction ---- - search_menu_actions = [find_action, - find_next_action, - find_previous_action, - replace_action, - gotoline_action] - - self.main.search_toolbar_actions = [find_action, - find_next_action, - replace_action] - - # ---- Edit menu/toolbar construction ---- - self.edit_menu_actions = [self.toggle_comment_action, - blockcomment_action, unblockcomment_action, - self.indent_action, self.unindent_action, - self.text_uppercase_action, - self.text_lowercase_action] - - # ---- Search menu/toolbar construction ---- - if not hasattr(self.main, 'search_menu_actions'): - # This list will not exist in the fast tests. - self.main.search_menu_actions = [] - - self.main.search_menu_actions = ( - search_menu_actions + self.main.search_menu_actions) - - # ---- Run menu/toolbar construction ---- - run_menu_actions = [run_action, run_cell_action, - run_cell_advance_action, - re_run_last_cell_action, MENU_SEPARATOR, - run_selected_action, run_to_line_action, - run_from_line_action, re_run_action, - configure_action, MENU_SEPARATOR] - self.main.run_menu_actions = ( - run_menu_actions + self.main.run_menu_actions) - run_toolbar_actions = [run_action, run_cell_action, - run_cell_advance_action, run_selected_action] - self.main.run_toolbar_actions += run_toolbar_actions - - # ---- Debug menu/toolbar construction ---- - debug_menu_actions = [ - self.debug_action, - self.debug_cell_action, - self.debug_next_action, - self.debug_step_action, - self.debug_return_action, - self.debug_continue_action, - self.debug_exit_action, - MENU_SEPARATOR, - set_clear_breakpoint_action, - set_cond_breakpoint_action, - clear_all_breakpoints_action, - ] - self.main.debug_menu_actions = ( - debug_menu_actions + self.main.debug_menu_actions) - debug_toolbar_actions = [ - self.debug_action, - self.debug_next_action, - self.debug_step_action, - self.debug_return_action, - self.debug_continue_action, - self.debug_exit_action - ] - self.main.debug_toolbar_actions += debug_toolbar_actions - - # ---- Source menu/toolbar construction ---- - source_menu_actions = [ - showblanks_action, - scrollpastend_action, - showindentguides_action, - showcodefolding_action, - show_classfunc_dropdown_action, - show_codestyle_warnings_action, - show_docstring_warnings_action, - underline_errors, - MENU_SEPARATOR, - self.todo_list_action, - self.warning_list_action, - self.previous_warning_action, - self.next_warning_action, - MENU_SEPARATOR, - self.previous_edit_cursor_action, - self.previous_cursor_action, - self.next_cursor_action, - MENU_SEPARATOR, - eol_menu, - trailingspaces_action, - fixindentation_action, - self.formatting_action - ] - self.main.source_menu_actions = ( - source_menu_actions + self.main.source_menu_actions) - - # ---- Dock widget and file dependent actions ---- - self.dock_toolbar_actions = ( - file_toolbar_actions + - [MENU_SEPARATOR] + - run_toolbar_actions + - [MENU_SEPARATOR] + - debug_toolbar_actions - ) - self.pythonfile_dependent_actions = [ - run_action, - configure_action, - set_clear_breakpoint_action, - set_cond_breakpoint_action, - self.debug_action, - self.debug_cell_action, - run_selected_action, - run_cell_action, - run_cell_advance_action, - re_run_last_cell_action, - blockcomment_action, - unblockcomment_action, - ] - self.cythonfile_compatible_actions = [run_action, configure_action] - self.file_dependent_actions = ( - self.pythonfile_dependent_actions + - [ - self.save_action, - save_as_action, - save_copy_as_action, - print_preview_action, - self.print_action, - self.save_all_action, - gotoline_action, - workdir_action, - self.close_action, - self.close_all_action, - self.toggle_comment_action, - self.revert_action, - self.indent_action, - self.unindent_action - ] - ) - self.stack_menu_actions = [gotoline_action, workdir_action] - - return self.file_dependent_actions - - def update_pdb_state(self, state, last_step): - """ - Enable/disable debugging actions and handle pdb state change. - - Some examples depending on the debugging state: - self.debug_action.setEnabled(not state) - self.debug_cell_action.setEnabled(not state) - self.debug_next_action.setEnabled(state) - self.debug_step_action.setEnabled(state) - self.debug_return_action.setEnabled(state) - self.debug_continue_action.setEnabled(state) - self.debug_exit_action.setEnabled(state) - """ - current_editor = self.get_current_editor() - if current_editor: - current_editor.update_debugger_panel_state(state, last_step) - - def register_plugin(self): - """Register plugin in Spyder's main window""" - completions = self.main.get_plugin(Plugins.Completions, error=False) - outlineexplorer = self.main.get_plugin( - Plugins.OutlineExplorer, error=False) - ipyconsole = self.main.get_plugin(Plugins.IPythonConsole, error=False) - - self.main.restore_scrollbar_position.connect( - self.restore_scrollbar_position) - self.main.console.sig_edit_goto_requested.connect(self.load) - self.redirect_stdio.connect(self.main.redirect_internalshell_stdio) - - if completions: - self.main.completions.sig_language_completions_available.connect( - self.register_completion_capabilities) - self.main.completions.sig_open_file.connect(self.load) - self.main.completions.sig_editor_rpc.connect(self._rpc_call) - self.main.completions.sig_stop_completions.connect( - self.stop_completion_services) - - self.sig_file_opened_closed_or_updated.connect( - self.main.completions.file_opened_closed_or_updated) - - if outlineexplorer: - self.set_outlineexplorer(self.main.outlineexplorer) - - if ipyconsole: - ipyconsole.register_spyder_kernel_call_handler( - 'cell_count', self.handle_cell_count) - ipyconsole.register_spyder_kernel_call_handler( - 'current_filename', self.handle_current_filename) - ipyconsole.register_spyder_kernel_call_handler( - 'get_file_code', self.handle_get_file_code) - ipyconsole.register_spyder_kernel_call_handler( - 'run_cell', self.handle_run_cell) - - self.add_dockwidget() - self.update_pdb_state(False, {}) - - # Add modes to switcher - self.switcher_manager = EditorSwitcherManager( - self, - self.main.switcher, - lambda: self.get_current_editor(), - lambda: self.get_current_editorstack(), - section=self.get_plugin_title()) - - def update_source_menu(self, options, **kwargs): - option_names = [opt[-1] if isinstance(opt, tuple) else opt - for opt in options] - named_options = dict(zip(option_names, options)) - for name, action in self.checkable_actions.items(): - if name in named_options: - if name == 'underline_errors': - section = 'editor' - opt = 'underline_errors' - else: - section = 'completions' - opt = named_options[name] - - state = self.get_option(opt, section=section) - - # Avoid triggering the action when this action changes state - # See: spyder-ide/spyder#9915 - action.blockSignals(True) - action.setChecked(state) - action.blockSignals(False) - - def update_font(self): - """Update font from Preferences""" - font = self.get_font() - color_scheme = self.get_color_scheme() - for editorstack in self.editorstacks: - editorstack.set_default_font(font, color_scheme) - completion_size = CONF.get('main', 'completion/size') - for finfo in editorstack.data: - comp_widget = finfo.editor.completion_widget - kite_call_to_action = finfo.editor.kite_call_to_action - comp_widget.setup_appearance(completion_size, font) - kite_call_to_action.setFont(font) - - def set_ancestor(self, ancestor): - """ - Set ancestor of child widgets like the CompletionWidget. - - Needed to properly set position of the widget based on the correct - parent/ancestor. - - See spyder-ide/spyder#11076 - """ - for editorstack in self.editorstacks: - for finfo in editorstack.data: - comp_widget = finfo.editor.completion_widget - kite_call_to_action = finfo.editor.kite_call_to_action - - # This is necessary to catch an error when the plugin is - # undocked and docked back, and (probably) a completion is - # in progress. - # Fixes spyder-ide/spyder#17486 - try: - comp_widget.setParent(ancestor) - kite_call_to_action.setParent(ancestor) - except RuntimeError: - pass - - def _create_checkable_action(self, text, conf_name, method=''): - """Helper function to create a checkable action. - - Args: - text (str): Text to be displayed in the action. - conf_name (str): configuration setting associated with the - action - method (str): name of EditorStack class that will be used - to update the changes in each editorstack. - """ - def toogle(checked): - self.switch_to_plugin() - self._toggle_checkable_action(checked, method, conf_name) - - action = create_action(self, text, toggled=toogle) - action.blockSignals(True) - - if conf_name not in ['pycodestyle', 'pydocstyle']: - action.setChecked(self.get_option(conf_name)) - else: - opt = CONF.get( - 'completions', - ('provider_configuration', 'lsp', 'values', conf_name), - False - ) - action.setChecked(opt) - - action.blockSignals(False) - - return action - - @Slot(bool, str, str) - def _toggle_checkable_action(self, checked, method_name, conf_name): - """ - Handle the toogle of a checkable action. - - Update editorstacks, PyLS and CONF. - - Args: - checked (bool): State of the action. - method_name (str): name of EditorStack class that will be used - to update the changes in each editorstack. - conf_name (str): configuration setting associated with the - action. - """ - if method_name: - if self.editorstacks: - for editorstack in self.editorstacks: - try: - method = getattr(editorstack, method_name) - method(checked) - except AttributeError as e: - logger.error(e, exc_info=True) - self.set_option(conf_name, checked) - else: - if conf_name in ('pycodestyle', 'pydocstyle'): - CONF.set( - 'completions', - ('provider_configuration', 'lsp', 'values', conf_name), - checked) - if self.main.get_plugin(Plugins.Completions, error=False): - completions = self.main.completions - completions.after_configuration_update([]) - - #------ Focus tabwidget - def __get_focused_editorstack(self): - fwidget = QApplication.focusWidget() - if isinstance(fwidget, EditorStack): - return fwidget - else: - for editorstack in self.editorstacks: - if editorstack.isAncestorOf(fwidget): - return editorstack - - def set_last_focused_editorstack(self, editorwindow, editorstack): - self.last_focused_editorstack[editorwindow] = editorstack - # very last editorstack - self.last_focused_editorstack[None] = editorstack - - def get_last_focused_editorstack(self, editorwindow=None): - return self.last_focused_editorstack[editorwindow] - - def remove_last_focused_editorstack(self, editorstack): - for editorwindow, widget in list( - self.last_focused_editorstack.items()): - if widget is editorstack: - self.last_focused_editorstack[editorwindow] = None - - def save_focused_editorstack(self): - editorstack = self.__get_focused_editorstack() - if editorstack is not None: - for win in [self]+self.editorwindows: - if win.isAncestorOf(editorstack): - self.set_last_focused_editorstack(win, editorstack) - - # ------ Handling editorstacks - def register_editorstack(self, editorstack): - self.editorstacks.append(editorstack) - self.register_widget_shortcuts(editorstack) - - if self.isAncestorOf(editorstack): - # editorstack is a child of the Editor plugin - self.set_last_focused_editorstack(self, editorstack) - editorstack.set_closable(len(self.editorstacks) > 1) - if self.outlineexplorer is not None: - editorstack.set_outlineexplorer( - self.outlineexplorer.get_widget()) - editorstack.set_find_widget(self.find_widget) - editorstack.reset_statusbar.connect(self.readwrite_status.hide) - editorstack.reset_statusbar.connect(self.encoding_status.hide) - editorstack.reset_statusbar.connect(self.cursorpos_status.hide) - editorstack.readonly_changed.connect( - self.readwrite_status.update_readonly) - editorstack.encoding_changed.connect( - self.encoding_status.update_encoding) - editorstack.sig_editor_cursor_position_changed.connect( - self.cursorpos_status.update_cursor_position) - editorstack.sig_editor_cursor_position_changed.connect( - self.current_editor_cursor_changed) - editorstack.sig_refresh_eol_chars.connect( - self.eol_status.update_eol) - editorstack.current_file_changed.connect( - self.vcs_status.update_vcs) - editorstack.file_saved.connect( - self.vcs_status.update_vcs_state) - - editorstack.set_io_actions(self.new_action, self.open_action, - self.save_action, self.revert_action) - editorstack.set_tempfile_path(self.TEMPFILE_PATH) - - settings = ( - ('set_todolist_enabled', 'todo_list'), - ('set_blanks_enabled', 'blank_spaces'), - ('set_underline_errors_enabled', 'underline_errors'), - ('set_scrollpastend_enabled', 'scroll_past_end'), - ('set_linenumbers_enabled', 'line_numbers'), - ('set_edgeline_enabled', 'edge_line'), - ('set_indent_guides', 'indent_guides'), - ('set_code_folding_enabled', 'code_folding'), - ('set_focus_to_editor', 'focus_to_editor'), - ('set_run_cell_copy', 'run_cell_copy'), - ('set_close_parentheses_enabled', 'close_parentheses'), - ('set_close_quotes_enabled', 'close_quotes'), - ('set_add_colons_enabled', 'add_colons'), - ('set_auto_unindent_enabled', 'auto_unindent'), - ('set_indent_chars', 'indent_chars'), - ('set_tab_stop_width_spaces', 'tab_stop_width_spaces'), - ('set_wrap_enabled', 'wrap'), - ('set_tabmode_enabled', 'tab_always_indent'), - ('set_stripmode_enabled', 'strip_trailing_spaces_on_modify'), - ('set_intelligent_backspace_enabled', 'intelligent_backspace'), - ('set_automatic_completions_enabled', 'automatic_completions'), - ('set_automatic_completions_after_chars', - 'automatic_completions_after_chars'), - ('set_automatic_completions_after_ms', - 'automatic_completions_after_ms'), - ('set_completions_hint_enabled', 'completions_hint'), - ('set_completions_hint_after_ms', - 'completions_hint_after_ms'), - ('set_highlight_current_line_enabled', 'highlight_current_line'), - ('set_highlight_current_cell_enabled', 'highlight_current_cell'), - ('set_occurrence_highlighting_enabled', 'occurrence_highlighting'), - ('set_occurrence_highlighting_timeout', 'occurrence_highlighting/timeout'), - ('set_checkeolchars_enabled', 'check_eol_chars'), - ('set_tabbar_visible', 'show_tab_bar'), - ('set_classfunc_dropdown_visible', 'show_class_func_dropdown'), - ('set_always_remove_trailing_spaces', 'always_remove_trailing_spaces'), - ('set_remove_trailing_newlines', 'always_remove_trailing_newlines'), - ('set_add_newline', 'add_newline'), - ('set_convert_eol_on_save', 'convert_eol_on_save'), - ('set_convert_eol_on_save_to', 'convert_eol_on_save_to'), - ) - - for method, setting in settings: - getattr(editorstack, method)(self.get_option(setting)) - - editorstack.set_help_enabled(CONF.get('help', 'connect/editor')) - - hover_hints = CONF.get( - 'completions', - ('provider_configuration', 'lsp', 'values', - 'enable_hover_hints'), - True - ) - - format_on_save = CONF.get( - 'completions', - ('provider_configuration', 'lsp', 'values', 'format_on_save'), - False - ) - - edge_line_columns = CONF.get( - 'completions', - ('provider_configuration', 'lsp', 'values', - 'pycodestyle/max_line_length'), - 79 - ) - - editorstack.set_hover_hints_enabled(hover_hints) - editorstack.set_format_on_save(format_on_save) - editorstack.set_edgeline_columns(edge_line_columns) - color_scheme = self.get_color_scheme() - editorstack.set_default_font(self.get_font(), color_scheme) - - editorstack.starting_long_process.connect(self.starting_long_process) - editorstack.ending_long_process.connect(self.ending_long_process) - - # Redirect signals - editorstack.sig_option_changed.connect(self.sig_option_changed) - editorstack.redirect_stdio.connect( - lambda state: self.redirect_stdio.emit(state)) - editorstack.exec_in_extconsole.connect( - lambda text, option: - self.exec_in_extconsole.emit(text, option)) - editorstack.run_cell_in_ipyclient.connect(self.run_cell_in_ipyclient) - editorstack.debug_cell_in_ipyclient.connect( - self.debug_cell_in_ipyclient) - editorstack.update_plugin_title.connect( - lambda: self.sig_update_plugin_title.emit()) - editorstack.editor_focus_changed.connect(self.save_focused_editorstack) - editorstack.editor_focus_changed.connect(self.main.plugin_focus_changed) - editorstack.editor_focus_changed.connect(self.sig_editor_focus_changed) - editorstack.zoom_in.connect(lambda: self.zoom(1)) - editorstack.zoom_out.connect(lambda: self.zoom(-1)) - editorstack.zoom_reset.connect(lambda: self.zoom(0)) - editorstack.sig_open_file.connect(self.report_open_file) - editorstack.sig_new_file.connect(lambda s: self.new(text=s)) - editorstack.sig_new_file[()].connect(self.new) - editorstack.sig_close_file.connect(self.close_file_in_all_editorstacks) - editorstack.sig_close_file.connect(self.remove_file_cursor_history) - editorstack.file_saved.connect(self.file_saved_in_editorstack) - editorstack.file_renamed_in_data.connect( - self.file_renamed_in_data_in_editorstack) - editorstack.opened_files_list_changed.connect( - self.opened_files_list_changed) - editorstack.active_languages_stats.connect( - self.update_active_languages) - editorstack.sig_go_to_definition.connect( - lambda fname, line, col: self.load( - fname, line, start_column=col)) - editorstack.sig_perform_completion_request.connect( - self.send_completion_request) - editorstack.todo_results_changed.connect(self.todo_results_changed) - editorstack.update_code_analysis_actions.connect( - self.update_code_analysis_actions) - editorstack.update_code_analysis_actions.connect( - self.update_todo_actions) - editorstack.refresh_file_dependent_actions.connect( - self.refresh_file_dependent_actions) - editorstack.refresh_save_all_action.connect(self.refresh_save_all_action) - editorstack.sig_refresh_eol_chars.connect(self.refresh_eol_chars) - editorstack.sig_refresh_formatting.connect(self.refresh_formatting) - editorstack.sig_breakpoints_saved.connect(self.breakpoints_saved) - editorstack.text_changed_at.connect(self.text_changed_at) - editorstack.current_file_changed.connect(self.current_file_changed) - editorstack.plugin_load.connect(self.load) - editorstack.plugin_load[()].connect(self.load) - editorstack.edit_goto.connect(self.load) - editorstack.sig_save_as.connect(self.save_as) - editorstack.sig_prev_edit_pos.connect(self.go_to_last_edit_location) - editorstack.sig_prev_cursor.connect(self.go_to_previous_cursor_position) - editorstack.sig_next_cursor.connect(self.go_to_next_cursor_position) - editorstack.sig_prev_warning.connect(self.go_to_previous_warning) - editorstack.sig_next_warning.connect(self.go_to_next_warning) - editorstack.sig_save_bookmark.connect(self.save_bookmark) - editorstack.sig_load_bookmark.connect(self.load_bookmark) - editorstack.sig_save_bookmarks.connect(self.save_bookmarks) - editorstack.sig_help_requested.connect(self.sig_help_requested) - - # Register editorstack's autosave component with plugin's autosave - # component - self.autosave.register_autosave_for_stack(editorstack.autosave) - - def unregister_editorstack(self, editorstack): - """Removing editorstack only if it's not the last remaining""" - self.remove_last_focused_editorstack(editorstack) - if len(self.editorstacks) > 1: - index = self.editorstacks.index(editorstack) - self.editorstacks.pop(index) - return True - else: - # editorstack was not removed! - return False - - def clone_editorstack(self, editorstack): - editorstack.clone_from(self.editorstacks[0]) - for finfo in editorstack.data: - self.register_widget_shortcuts(finfo.editor) - - @Slot(str, str) - def close_file_in_all_editorstacks(self, editorstack_id_str, filename): - for editorstack in self.editorstacks: - if str(id(editorstack)) != editorstack_id_str: - editorstack.blockSignals(True) - index = editorstack.get_index_from_filename(filename) - editorstack.close_file(index, force=True) - editorstack.blockSignals(False) - - @Slot(str, str, str) - def file_saved_in_editorstack(self, editorstack_id_str, - original_filename, filename): - """A file was saved in editorstack, this notifies others""" - for editorstack in self.editorstacks: - if str(id(editorstack)) != editorstack_id_str: - editorstack.file_saved_in_other_editorstack(original_filename, - filename) - - @Slot(str, str, str) - def file_renamed_in_data_in_editorstack(self, editorstack_id_str, - original_filename, filename): - """A file was renamed in data in editorstack, this notifies others""" - for editorstack in self.editorstacks: - if str(id(editorstack)) != editorstack_id_str: - editorstack.rename_in_data(original_filename, filename) - - #------ Handling editor windows - def setup_other_windows(self): - """Setup toolbars and menus for 'New window' instances""" - # TODO: All the actions here should be taken from - # the MainMenus plugin - file_menu_actions = self.main.mainmenu.get_application_menu( - ApplicationMenus.File).get_actions() - tools_menu_actions = self.main.mainmenu.get_application_menu( - ApplicationMenus.Tools).get_actions() - help_menu_actions = self.main.mainmenu.get_application_menu( - ApplicationMenus.Help).get_actions() - - self.toolbar_list = ((_("File toolbar"), "file_toolbar", - self.main.file_toolbar_actions), - (_("Run toolbar"), "run_toolbar", - self.main.run_toolbar_actions), - (_("Debug toolbar"), "debug_toolbar", - self.main.debug_toolbar_actions)) - - self.menu_list = ((_("&File"), file_menu_actions), - (_("&Edit"), self.main.edit_menu_actions), - (_("&Search"), self.main.search_menu_actions), - (_("Sour&ce"), self.main.source_menu_actions), - (_("&Run"), self.main.run_menu_actions), - (_("&Tools"), tools_menu_actions), - (_("&View"), []), - (_("&Help"), help_menu_actions)) - # Create pending new windows: - for layout_settings in self.editorwindows_to_be_created: - win = self.create_new_window() - win.set_layout_settings(layout_settings) - - def switch_to_plugin(self): - """ - Reimplemented method to deactivate shortcut when - opening a new window. - """ - if not self.editorwindows: - super(Editor, self).switch_to_plugin() - - def create_new_window(self): - window = EditorMainWindow( - self, self.stack_menu_actions, self.toolbar_list, self.menu_list) - window.add_toolbars_to_menu("&View", window.get_toolbars()) - window.load_toolbars() - window.resize(self.size()) - window.show() - window.editorwidget.editorsplitter.editorstack.new_window = True - self.register_editorwindow(window) - window.destroyed.connect(lambda: self.unregister_editorwindow(window)) - return window - - def register_editorwindow(self, window): - self.editorwindows.append(window) - - def unregister_editorwindow(self, window): - self.editorwindows.pop(self.editorwindows.index(window)) - - - #------ Accessors - def get_filenames(self): - return [finfo.filename for finfo in self.editorstacks[0].data] - - def get_filename_index(self, filename): - return self.editorstacks[0].has_filename(filename) - - def get_current_editorstack(self, editorwindow=None): - if self.editorstacks is not None: - if len(self.editorstacks) == 1: - editorstack = self.editorstacks[0] - else: - editorstack = self.__get_focused_editorstack() - if editorstack is None or editorwindow is not None: - editorstack = self.get_last_focused_editorstack( - editorwindow) - if editorstack is None: - editorstack = self.editorstacks[0] - return editorstack - - def get_current_editor(self): - editorstack = self.get_current_editorstack() - if editorstack is not None: - return editorstack.get_current_editor() - - def get_current_finfo(self): - editorstack = self.get_current_editorstack() - if editorstack is not None: - return editorstack.get_current_finfo() - - def get_current_filename(self): - editorstack = self.get_current_editorstack() - if editorstack is not None: - return editorstack.get_current_filename() - - def get_current_language(self): - editorstack = self.get_current_editorstack() - if editorstack is not None: - return editorstack.get_current_language() - - def is_file_opened(self, filename=None): - return self.editorstacks[0].is_file_opened(filename) - - def set_current_filename(self, filename, editorwindow=None, focus=True): - """Set focus to *filename* if this file has been opened. - - Return the editor instance associated to *filename*. - """ - editorstack = self.get_current_editorstack(editorwindow) - return editorstack.set_current_filename(filename, focus) - - def set_path(self): - for finfo in self.editorstacks[0].data: - finfo.path = self.main.get_spyder_pythonpath() - - #------ Refresh methods - def refresh_file_dependent_actions(self): - """Enable/disable file dependent actions - (only if dockwidget is visible)""" - if self.dockwidget and self.dockwidget.isVisible(): - enable = self.get_current_editor() is not None - for action in self.file_dependent_actions: - action.setEnabled(enable) - - def refresh_save_all_action(self): - """Enable 'Save All' if there are files to be saved""" - editorstack = self.get_current_editorstack() - if editorstack: - state = any(finfo.editor.document().isModified() or finfo.newly_created - for finfo in editorstack.data) - self.save_all_action.setEnabled(state) - - def update_warning_menu(self): - """Update warning list menu""" - editor = self.get_current_editor() - check_results = editor.get_current_warnings() - self.warning_menu.clear() - filename = self.get_current_filename() - for message, line_number in check_results: - error = 'syntax' in message - text = message[:1].upper() + message[1:] - icon = ima.icon('error') if error else ima.icon('warning') - slot = lambda _checked, _l=line_number: self.load(filename, goto=_l) - action = create_action(self, text=text, icon=icon) - action.triggered[bool].connect(slot) - self.warning_menu.addAction(action) - - def update_todo_menu(self): - """Update todo list menu""" - editorstack = self.get_current_editorstack() - results = editorstack.get_todo_results() - self.todo_menu.clear() - filename = self.get_current_filename() - for text, line0 in results: - icon = ima.icon('todo') - slot = lambda _checked, _l=line0: self.load(filename, goto=_l) - action = create_action(self, text=text, icon=icon) - action.triggered[bool].connect(slot) - self.todo_menu.addAction(action) - self.update_todo_actions() - - def todo_results_changed(self): - """ - Synchronize todo results between editorstacks - Refresh todo list navigation buttons - """ - editorstack = self.get_current_editorstack() - results = editorstack.get_todo_results() - index = editorstack.get_stack_index() - if index != -1: - filename = editorstack.data[index].filename - for other_editorstack in self.editorstacks: - if other_editorstack is not editorstack: - other_editorstack.set_todo_results(filename, results) - self.update_todo_actions() - - def refresh_eol_chars(self, os_name): - os_name = to_text_string(os_name) - self.__set_eol_chars = False - if os_name == 'nt': - self.win_eol_action.setChecked(True) - elif os_name == 'posix': - self.linux_eol_action.setChecked(True) - else: - self.mac_eol_action.setChecked(True) - self.__set_eol_chars = True - - def refresh_formatting(self, status): - self.formatting_action.setEnabled(status) - - def refresh_formatter_name(self): - formatter = CONF.get( - 'completions', - ('provider_configuration', 'lsp', 'values', 'formatting'), - '') - self.formatting_action.setText( - _('Format file or selection with {0}').format( - formatter.capitalize())) - - #------ Slots - def opened_files_list_changed(self): - """ - Opened files list has changed: - --> open/close file action - --> modification ('*' added to title) - --> current edited file has changed - """ - # Refresh Python file dependent actions: - editor = self.get_current_editor() - if editor: - python_enable = editor.is_python_or_ipython() - cython_enable = python_enable or ( - programs.is_module_installed('Cython') and editor.is_cython()) - for action in self.pythonfile_dependent_actions: - if action in self.cythonfile_compatible_actions: - enable = cython_enable - else: - enable = python_enable - action.setEnabled(enable) - self.sig_file_opened_closed_or_updated.emit( - self.get_current_filename(), self.get_current_language()) - - def update_code_analysis_actions(self): - """Update actions in the warnings menu.""" - editor = self.get_current_editor() - - # To fix an error at startup - if editor is None: - return - - # Update actions state if there are errors present - for action in (self.warning_list_action, self.previous_warning_action, - self.next_warning_action): - action.setEnabled(editor.errors_present()) - - def update_todo_actions(self): - editorstack = self.get_current_editorstack() - results = editorstack.get_todo_results() - state = (self.get_option('todo_list') and - results is not None and len(results)) - if state is not None: - self.todo_list_action.setEnabled(state) - - @Slot(set) - def update_active_languages(self, languages): - if self.main.get_plugin(Plugins.Completions, error=False): - self.main.completions.update_client_status(languages) - - # ------ Bookmarks - def save_bookmarks(self, filename, bookmarks): - """Receive bookmark changes and save them.""" - filename = to_text_string(filename) - bookmarks = to_text_string(bookmarks) - filename = osp.normpath(osp.abspath(filename)) - bookmarks = eval(bookmarks) - save_bookmarks(filename, bookmarks) - - #------ File I/O - def __load_temp_file(self): - """Load temporary file from a text file in user home directory""" - if not osp.isfile(self.TEMPFILE_PATH): - # Creating temporary file - default = ['# -*- coding: utf-8 -*-', - '"""', _("Spyder Editor"), '', - _("This is a temporary script file."), - '"""', '', ''] - text = os.linesep.join([encoding.to_unicode(qstr) - for qstr in default]) - try: - encoding.write(to_text_string(text), self.TEMPFILE_PATH, - 'utf-8') - except EnvironmentError: - self.new() - return - - self.load(self.TEMPFILE_PATH) - - @Slot() - def __set_workdir(self): - """Set current script directory as working directory""" - fname = self.get_current_filename() - if fname is not None: - directory = osp.dirname(osp.abspath(fname)) - self.sig_dir_opened.emit(directory) - - def __add_recent_file(self, fname): - """Add to recent file list""" - if fname is None: - return - if fname in self.recent_files: - self.recent_files.remove(fname) - self.recent_files.insert(0, fname) - if len(self.recent_files) > self.get_option('max_recent_files'): - self.recent_files.pop(-1) - - def _clone_file_everywhere(self, finfo): - """Clone file (*src_editor* widget) in all editorstacks - Cloning from the first editorstack in which every single new editor - is created (when loading or creating a new file)""" - for editorstack in self.editorstacks[1:]: - editor = editorstack.clone_editor_from(finfo, set_current=False) - self.register_widget_shortcuts(editor) - - - @Slot() - @Slot(str) - def new(self, fname=None, editorstack=None, text=None): - """ - Create a new file - Untitled - - fname=None --> fname will be 'untitledXX.py' but do not create file - fname= --> create file - """ - # If no text is provided, create default content - empty = False - try: - if text is None: - default_content = True - text, enc = encoding.read(self.TEMPLATE_PATH) - enc_match = re.search(r'-*- coding: ?([a-z0-9A-Z\-]*) -*-', - text) - if enc_match: - enc = enc_match.group(1) - # Initialize template variables - # Windows - username = encoding.to_unicode_from_fs( - os.environ.get('USERNAME', '')) - # Linux, Mac OS X - if not username: - username = encoding.to_unicode_from_fs( - os.environ.get('USER', '-')) - VARS = { - 'date': time.ctime(), - 'username': username, - } - try: - text = text % VARS - except Exception: - pass - else: - default_content = False - enc = encoding.read(self.TEMPLATE_PATH)[1] - except (IOError, OSError): - text = '' - enc = 'utf-8' - default_content = True - - create_fname = lambda n: to_text_string(_("untitled")) + ("%d.py" % n) - # Creating editor widget - if editorstack is None: - current_es = self.get_current_editorstack() - else: - current_es = editorstack - created_from_here = fname is None - if created_from_here: - if self.untitled_num == 0: - for finfo in current_es.data: - current_filename = finfo.editor.filename - if _("untitled") in current_filename: - # Start the counter of the untitled_num with respect - # to this number if there's other untitled file in - # spyder. Please see spyder-ide/spyder#7831 - fname_data = osp.splitext(current_filename) - try: - act_num = int( - fname_data[0].split(_("untitled"))[-1]) - self.untitled_num = act_num + 1 - except ValueError: - # Catch the error in case the user has something - # different from a number after the untitled - # part. - # Please see spyder-ide/spyder#12892 - self.untitled_num = 0 - while True: - fname = create_fname(self.untitled_num) - self.untitled_num += 1 - if not osp.isfile(fname): - break - basedir = getcwd_or_home() - - projects = self.main.get_plugin(Plugins.Projects, error=False) - if projects and projects.get_active_project() is not None: - basedir = projects.get_active_project_path() - else: - c_fname = self.get_current_filename() - if c_fname is not None and c_fname != self.TEMPFILE_PATH: - basedir = osp.dirname(c_fname) - fname = osp.abspath(osp.join(basedir, fname)) - else: - # QString when triggered by a Qt signal - fname = osp.abspath(to_text_string(fname)) - index = current_es.has_filename(fname) - if index is not None and not current_es.close_file(index): - return - - # Creating the editor widget in the first editorstack (the one that - # can't be destroyed), then cloning this editor widget in all other - # editorstacks: - # Setting empty to True by default to avoid the additional space - # created at the end of the templates. - # See: spyder-ide/spyder#12596 - finfo = self.editorstacks[0].new(fname, enc, text, default_content, - empty=True) - finfo.path = self.main.get_spyder_pythonpath() - self._clone_file_everywhere(finfo) - current_editor = current_es.set_current_filename(finfo.filename) - self.register_widget_shortcuts(current_editor) - if not created_from_here: - self.save(force=True) - - def edit_template(self): - """Edit new file template""" - self.load(self.TEMPLATE_PATH) - - def update_recent_file_menu(self): - """Update recent file menu""" - recent_files = [] - for fname in self.recent_files: - if osp.isfile(fname): - recent_files.append(fname) - self.recent_file_menu.clear() - if recent_files: - for fname in recent_files: - action = create_action( - self, fname, - icon=ima.get_icon_by_extension_or_type( - fname, scale_factor=1.0)) - action.triggered[bool].connect(self.load) - action.setData(to_qvariant(fname)) - self.recent_file_menu.addAction(action) - self.clear_recent_action.setEnabled(len(recent_files) > 0) - add_actions(self.recent_file_menu, (None, self.max_recent_action, - self.clear_recent_action)) - - @Slot() - def clear_recent_files(self): - """Clear recent files list""" - self.recent_files = [] - - @Slot() - def change_max_recent_files(self): - "Change max recent files entries""" - editorstack = self.get_current_editorstack() - mrf, valid = QInputDialog.getInt(editorstack, _('Editor'), - _('Maximum number of recent files'), - self.get_option('max_recent_files'), 1, 35) - if valid: - self.set_option('max_recent_files', mrf) - - @Slot() - @Slot(str) - @Slot(str, int, str) - @Slot(str, int, str, object) - def load(self, filenames=None, goto=None, word='', - editorwindow=None, processevents=True, start_column=None, - end_column=None, set_focus=True, add_where='end'): - """ - Load a text file - editorwindow: load in this editorwindow (useful when clicking on - outline explorer with multiple editor windows) - processevents: determines if processEvents() should be called at the - end of this method (set to False to prevent keyboard events from - creeping through to the editor during debugging) - If goto is not none it represent a line to go to. start_column is - the start position in this line and end_column the length - (So that the end position is start_column + end_column) - Alternatively, the first match of word is used as a position. - """ - cursor_history_state = self.__ignore_cursor_history - self.__ignore_cursor_history = True - # Switch to editor before trying to load a file - try: - self.switch_to_plugin() - except AttributeError: - pass - - editor0 = self.get_current_editor() - if editor0 is not None: - filename0 = self.get_current_filename() - else: - filename0 = None - if not filenames: - # Recent files action - action = self.sender() - if isinstance(action, QAction): - filenames = from_qvariant(action.data(), to_text_string) - if not filenames: - basedir = getcwd_or_home() - if self.edit_filetypes is None: - self.edit_filetypes = get_edit_filetypes() - if self.edit_filters is None: - self.edit_filters = get_edit_filters() - - c_fname = self.get_current_filename() - if c_fname is not None and c_fname != self.TEMPFILE_PATH: - basedir = osp.dirname(c_fname) - - self.redirect_stdio.emit(False) - parent_widget = self.get_current_editorstack() - if filename0 is not None: - selectedfilter = get_filter(self.edit_filetypes, - osp.splitext(filename0)[1]) - else: - selectedfilter = '' - - if not running_under_pytest(): - # See: spyder-ide/spyder#3291 - if sys.platform == 'darwin': - dialog = QFileDialog( - parent=parent_widget, - caption=_("Open file"), - directory=basedir, - ) - dialog.setNameFilters(self.edit_filters.split(';;')) - dialog.setOption(QFileDialog.HideNameFilterDetails, True) - dialog.setFilter(QDir.AllDirs | QDir.Files | QDir.Drives - | QDir.Hidden) - dialog.setFileMode(QFileDialog.ExistingFiles) - - if dialog.exec_(): - filenames = dialog.selectedFiles() - else: - filenames, _sf = getopenfilenames( - parent_widget, - _("Open file"), - basedir, - self.edit_filters, - selectedfilter=selectedfilter, - options=QFileDialog.HideNameFilterDetails, - ) - else: - # Use a Qt (i.e. scriptable) dialog for pytest - dialog = QFileDialog(parent_widget, _("Open file"), - options=QFileDialog.DontUseNativeDialog) - if dialog.exec_(): - filenames = dialog.selectedFiles() - - self.redirect_stdio.emit(True) - - if filenames: - filenames = [osp.normpath(fname) for fname in filenames] - else: - self.__ignore_cursor_history = cursor_history_state - return - - focus_widget = QApplication.focusWidget() - if self.editorwindows and not self.dockwidget.isVisible(): - # We override the editorwindow variable to force a focus on - # the editor window instead of the hidden editor dockwidget. - # See spyder-ide/spyder#5742. - if editorwindow not in self.editorwindows: - editorwindow = self.editorwindows[0] - editorwindow.setFocus() - editorwindow.raise_() - elif (self.dockwidget and not self._ismaximized - and not self.dockwidget.isAncestorOf(focus_widget) - and not isinstance(focus_widget, CodeEditor)): - self.switch_to_plugin() - - def _convert(fname): - fname = osp.abspath(encoding.to_unicode_from_fs(fname)) - if os.name == 'nt' and len(fname) >= 2 and fname[1] == ':': - fname = fname[0].upper()+fname[1:] - return fname - - if hasattr(filenames, 'replaceInStrings'): - # This is a QStringList instance (PyQt API #1), converting to list: - filenames = list(filenames) - if not isinstance(filenames, list): - filenames = [_convert(filenames)] - else: - filenames = [_convert(fname) for fname in list(filenames)] - if isinstance(goto, int): - goto = [goto] - elif goto is not None and len(goto) != len(filenames): - goto = None - - for index, filename in enumerate(filenames): - # -- Do not open an already opened file - focus = set_focus and index == 0 - current_editor = self.set_current_filename(filename, - editorwindow, - focus=focus) - if current_editor is None: - # -- Not a valid filename: - if not osp.isfile(filename): - continue - # -- - current_es = self.get_current_editorstack(editorwindow) - # Creating the editor widget in the first editorstack - # (the one that can't be destroyed), then cloning this - # editor widget in all other editorstacks: - finfo = self.editorstacks[0].load( - filename, set_current=False, add_where=add_where, - processevents=processevents) - finfo.path = self.main.get_spyder_pythonpath() - self._clone_file_everywhere(finfo) - current_editor = current_es.set_current_filename(filename, - focus=focus) - current_editor.debugger.load_breakpoints() - current_editor.set_bookmarks(load_bookmarks(filename)) - self.register_widget_shortcuts(current_editor) - current_es.analyze_script() - self.__add_recent_file(filename) - if goto is not None: # 'word' is assumed to be None as well - current_editor.go_to_line(goto[index], word=word, - start_column=start_column, - end_column=end_column) - current_editor.clearFocus() - current_editor.setFocus() - current_editor.window().raise_() - if processevents: - QApplication.processEvents() - else: - # processevents is false only when calling from debugging - current_editor.sig_debug_stop.emit(goto[index]) - - ipyconsole = self.main.get_plugin( - Plugins.IPythonConsole, error=False) - if ipyconsole: - current_sw = ipyconsole.get_current_shellwidget() - current_sw.sig_prompt_ready.connect( - current_editor.sig_debug_stop[()]) - current_pdb_state = ipyconsole.get_pdb_state() - pdb_last_step = ipyconsole.get_pdb_last_step() - self.update_pdb_state(current_pdb_state, pdb_last_step) - - self.__ignore_cursor_history = cursor_history_state - self.add_cursor_to_history() - - def _create_print_editor(self): - """Create a SimpleCodeEditor instance to print file contents.""" - editor = SimpleCodeEditor(self) - editor.setup_editor( - color_scheme="scintilla", highlight_current_line=False - ) - return editor - - @Slot() - def print_file(self): - """Print current file.""" - editor = self.get_current_editor() - filename = self.get_current_filename() - - # Set print editor - self._print_editor.set_text(editor.toPlainText()) - self._print_editor.set_language(editor.language) - self._print_editor.set_font(self.get_font()) - - # Create printer - printer = Printer(mode=QPrinter.HighResolution, - header_font=self.get_font()) - print_dialog = QPrintDialog(printer, self._print_editor) - - # Adjust print options when user has selected text - if editor.has_selected_text(): - print_dialog.setOption(QAbstractPrintDialog.PrintSelection, True) - - # Copy selection from current editor to print editor - cursor_1 = editor.textCursor() - start, end = cursor_1.selectionStart(), cursor_1.selectionEnd() - - cursor_2 = self._print_editor.textCursor() - cursor_2.setPosition(start) - cursor_2.setPosition(end, QTextCursor.KeepAnchor) - self._print_editor.setTextCursor(cursor_2) - - # Print - self.redirect_stdio.emit(False) - answer = print_dialog.exec_() - self.redirect_stdio.emit(True) - - if answer == QDialog.Accepted: - self.starting_long_process(_("Printing...")) - printer.setDocName(filename) - self._print_editor.print_(printer) - self.ending_long_process() - - # Clear selection - self._print_editor.textCursor().removeSelectedText() - - @Slot() - def print_preview(self): - """Print preview for current file.""" - editor = self.get_current_editor() - - # Set print editor - self._print_editor.set_text(editor.toPlainText()) - self._print_editor.set_language(editor.language) - self._print_editor.set_font(self.get_font()) - - # Create printer - printer = Printer(mode=QPrinter.HighResolution, - header_font=self.get_font()) - - # Create preview - preview = QPrintPreviewDialog(printer, self) - preview.setWindowFlags(Qt.Window) - preview.paintRequested.connect( - lambda printer: self._print_editor.print_(printer) - ) - - # Show preview - self.redirect_stdio.emit(False) - preview.exec_() - self.redirect_stdio.emit(True) - - def can_close_file(self, filename=None): - """ - Check if a file can be closed taking into account debugging state. - """ - if not CONF.get('ipython_console', 'pdb_prevent_closing'): - return True - ipyconsole = self.main.get_plugin(Plugins.IPythonConsole, error=False) - - debugging = False - last_pdb_step = {} - if ipyconsole: - debugging = ipyconsole.get_pdb_state() - last_pdb_step = ipyconsole.get_pdb_last_step() - - can_close = True - if debugging and 'fname' in last_pdb_step and filename: - if osp.normcase(last_pdb_step['fname']) == osp.normcase(filename): - can_close = False - self.sig_file_debug_message_requested.emit() - elif debugging: - can_close = False - self.sig_file_debug_message_requested.emit() - return can_close - - @Slot() - def close_file(self): - """Close current file""" - filename = self.get_current_filename() - if self.can_close_file(filename=filename): - editorstack = self.get_current_editorstack() - editorstack.close_file() - - @Slot() - def close_all_files(self): - """Close all opened scripts""" - self.editorstacks[0].close_all_files() - - @Slot() - def save(self, index=None, force=False): - """Save file""" - editorstack = self.get_current_editorstack() - return editorstack.save(index=index, force=force) - - @Slot() - def save_as(self): - """Save *as* the currently edited file""" - editorstack = self.get_current_editorstack() - if editorstack.save_as(): - fname = editorstack.get_current_filename() - self.__add_recent_file(fname) - - @Slot() - def save_copy_as(self): - """Save *copy as* the currently edited file""" - editorstack = self.get_current_editorstack() - editorstack.save_copy_as() - - @Slot() - def save_all(self, save_new_files=True): - """Save all opened files""" - self.get_current_editorstack().save_all(save_new_files=save_new_files) - - @Slot() - def revert(self): - """Revert the currently edited file from disk""" - editorstack = self.get_current_editorstack() - editorstack.revert() - - @Slot() - def find(self): - """Find slot""" - editorstack = self.get_current_editorstack() - editorstack.find_widget.show() - editorstack.find_widget.search_text.setFocus() - - @Slot() - def find_next(self): - """Fnd next slot""" - editorstack = self.get_current_editorstack() - editorstack.find_widget.find_next() - - @Slot() - def find_previous(self): - """Find previous slot""" - editorstack = self.get_current_editorstack() - editorstack.find_widget.find_previous() - - @Slot() - def replace(self): - """Replace slot""" - editorstack = self.get_current_editorstack() - editorstack.find_widget.show_replace() - - def open_last_closed(self): - """ Reopens the last closed tab.""" - editorstack = self.get_current_editorstack() - last_closed_files = editorstack.get_last_closed_files() - if (len(last_closed_files) > 0): - file_to_open = last_closed_files[0] - last_closed_files.remove(file_to_open) - editorstack.set_last_closed_files(last_closed_files) - self.load(file_to_open) - - #------ Explorer widget - def close_file_from_name(self, filename): - """Close file from its name""" - filename = osp.abspath(to_text_string(filename)) - index = self.editorstacks[0].has_filename(filename) - if index is not None: - self.editorstacks[0].close_file(index) - - def removed(self, filename): - """File was removed in file explorer widget or in project explorer""" - self.close_file_from_name(filename) - - def removed_tree(self, dirname): - """Directory was removed in project explorer widget""" - dirname = osp.abspath(to_text_string(dirname)) - for fname in self.get_filenames(): - if osp.abspath(fname).startswith(dirname): - self.close_file_from_name(fname) - - def renamed(self, source, dest): - """ - Propagate file rename to editor stacks and autosave component. - - This function is called when a file is renamed in the file explorer - widget or the project explorer. The file may not be opened in the - editor. - """ - filename = osp.abspath(to_text_string(source)) - index = self.editorstacks[0].has_filename(filename) - if index is not None: - for editorstack in self.editorstacks: - editorstack.rename_in_data(filename, - new_filename=to_text_string(dest)) - self.editorstacks[0].autosave.file_renamed( - filename, to_text_string(dest)) - - def renamed_tree(self, source, dest): - """Directory was renamed in file explorer or in project explorer.""" - dirname = osp.abspath(to_text_string(source)) - tofile = to_text_string(dest) - for fname in self.get_filenames(): - if osp.abspath(fname).startswith(dirname): - new_filename = fname.replace(dirname, tofile) - self.renamed(source=fname, dest=new_filename) - - #------ Source code - @Slot() - def indent(self): - """Indent current line or selection""" - editor = self.get_current_editor() - if editor is not None: - editor.indent() - - @Slot() - def unindent(self): - """Unindent current line or selection""" - editor = self.get_current_editor() - if editor is not None: - editor.unindent() - - @Slot() - def text_uppercase(self): - """Change current line or selection to uppercase.""" - editor = self.get_current_editor() - if editor is not None: - editor.transform_to_uppercase() - - @Slot() - def text_lowercase(self): - """Change current line or selection to lowercase.""" - editor = self.get_current_editor() - if editor is not None: - editor.transform_to_lowercase() - - @Slot() - def toggle_comment(self): - """Comment current line or selection""" - editor = self.get_current_editor() - if editor is not None: - editor.toggle_comment() - - @Slot() - def blockcomment(self): - """Block comment current line or selection""" - editor = self.get_current_editor() - if editor is not None: - editor.blockcomment() - - @Slot() - def unblockcomment(self): - """Un-block comment current line or selection""" - editor = self.get_current_editor() - if editor is not None: - editor.unblockcomment() - @Slot() - def go_to_next_todo(self): - self.switch_to_plugin() - editor = self.get_current_editor() - editor.go_to_next_todo() - filename = self.get_current_filename() - cursor = editor.textCursor() - self.add_cursor_to_history(filename, cursor) - - @Slot() - def go_to_next_warning(self): - self.switch_to_plugin() - editor = self.get_current_editor() - editor.go_to_next_warning() - filename = self.get_current_filename() - cursor = editor.textCursor() - self.add_cursor_to_history(filename, cursor) - - @Slot() - def go_to_previous_warning(self): - self.switch_to_plugin() - editor = self.get_current_editor() - editor.go_to_previous_warning() - filename = self.get_current_filename() - cursor = editor.textCursor() - self.add_cursor_to_history(filename, cursor) - - def toggle_eol_chars(self, os_name, checked): - if checked: - editor = self.get_current_editor() - if self.__set_eol_chars: - self.switch_to_plugin() - editor.set_eol_chars( - eol_chars=sourcecode.get_eol_chars_from_os_name(os_name) - ) - - @Slot() - def remove_trailing_spaces(self): - self.switch_to_plugin() - editorstack = self.get_current_editorstack() - editorstack.remove_trailing_spaces() - - @Slot() - def format_document_or_selection(self): - self.switch_to_plugin() - editorstack = self.get_current_editorstack() - editorstack.format_document_or_selection() - - @Slot() - def fix_indentation(self): - self.switch_to_plugin() - editorstack = self.get_current_editorstack() - editorstack.fix_indentation() - - #------ Cursor position history management - def update_cursorpos_actions(self): - self.previous_edit_cursor_action.setEnabled( - self.last_edit_cursor_pos is not None) - self.previous_cursor_action.setEnabled( - len(self.cursor_undo_history) > 0) - self.next_cursor_action.setEnabled( - len(self.cursor_redo_history) > 0) - - def add_cursor_to_history(self, filename=None, cursor=None): - if self.__ignore_cursor_history: - return - if filename is None: - filename = self.get_current_filename() - if cursor is None: - editor = self._get_editor(filename) - if editor is None: - return - cursor = editor.textCursor() - - replace_last_entry = False - if len(self.cursor_undo_history) > 0: - fname, hist_cursor = self.cursor_undo_history[-1] - if fname == filename: - if cursor.blockNumber() == hist_cursor.blockNumber(): - # Only one cursor per line - replace_last_entry = True - - if replace_last_entry: - self.cursor_undo_history.pop() - else: - # Drop redo stack as we moved - self.cursor_redo_history = [] - - self.cursor_undo_history.append((filename, cursor)) - self.update_cursorpos_actions() - - def text_changed_at(self, filename, position): - self.last_edit_cursor_pos = (to_text_string(filename), position) - - def current_file_changed(self, filename, position, line, column): - cursor = self.get_current_editor().textCursor() - self.add_cursor_to_history(to_text_string(filename), cursor) - - # Hide any open tooltips - current_stack = self.get_current_editorstack() - if current_stack is not None: - current_stack.hide_tooltip() - - # Update debugging state - ipyconsole = self.main.get_plugin(Plugins.IPythonConsole, error=False) - if ipyconsole is not None: - pdb_state = ipyconsole.get_pdb_state() - pdb_last_step = ipyconsole.get_pdb_last_step() - self.update_pdb_state(pdb_state, pdb_last_step) - - def current_editor_cursor_changed(self, line, column): - """Handles the change of the cursor inside the current editor.""" - code_editor = self.get_current_editor() - filename = code_editor.filename - cursor = code_editor.textCursor() - self.add_cursor_to_history( - to_text_string(filename), cursor) - - def remove_file_cursor_history(self, id, filename): - """Remove the cursor history of a file if the file is closed.""" - new_history = [] - for i, (cur_filename, cursor) in enumerate( - self.cursor_undo_history): - if cur_filename != filename: - new_history.append((cur_filename, cursor)) - self.cursor_undo_history = new_history - - new_redo_history = [] - for i, (cur_filename, cursor) in enumerate( - self.cursor_redo_history): - if cur_filename != filename: - new_redo_history.append((cur_filename, cursor)) - self.cursor_redo_history = new_redo_history - - @Slot() - def go_to_last_edit_location(self): - if self.last_edit_cursor_pos is not None: - filename, position = self.last_edit_cursor_pos - if not osp.isfile(filename): - self.last_edit_cursor_pos = None - return - else: - self.load(filename) - editor = self.get_current_editor() - if position < editor.document().characterCount(): - editor.set_cursor_position(position) - - def _pop_next_cursor_diff(self, history, current_filename, current_cursor): - """Get the next cursor from history that is different from current.""" - while history: - filename, cursor = history.pop() - if (filename != current_filename or - cursor.position() != current_cursor.position()): - return filename, cursor - return None, None - - def _history_steps(self, number_steps, - backwards_history, forwards_history, - current_filename, current_cursor): - """ - Move number_steps in the forwards_history, filling backwards_history. - """ - for i in range(number_steps): - if len(forwards_history) > 0: - # Put the current cursor in history - backwards_history.append( - (current_filename, current_cursor)) - # Extract the next different cursor - current_filename, current_cursor = ( - self._pop_next_cursor_diff( - forwards_history, - current_filename, current_cursor)) - if current_cursor is None: - # Went too far, back up once - current_filename, current_cursor = ( - backwards_history.pop()) - return current_filename, current_cursor - - - def __move_cursor_position(self, index_move): - """ - Move the cursor position forward or backward in the cursor - position history by the specified index increment. - """ - self.__ignore_cursor_history = True - # Remove last position as it will be replaced by the current position - if self.cursor_undo_history: - self.cursor_undo_history.pop() - - # Update last position on the line - current_filename = self.get_current_filename() - current_cursor = self.get_current_editor().textCursor() - - if index_move < 0: - # Undo - current_filename, current_cursor = self._history_steps( - -index_move, - self.cursor_redo_history, - self.cursor_undo_history, - current_filename, current_cursor) - - else: - # Redo - current_filename, current_cursor = self._history_steps( - index_move, - self.cursor_undo_history, - self.cursor_redo_history, - current_filename, current_cursor) - - # Place current cursor in history - self.cursor_undo_history.append( - (current_filename, current_cursor)) - filenames = self.get_current_editorstack().get_filenames() - if (not osp.isfile(current_filename) - and current_filename not in filenames): - self.cursor_undo_history.pop() - else: - self.load(current_filename) - editor = self.get_current_editor() - editor.setTextCursor(current_cursor) - editor.ensureCursorVisible() - self.__ignore_cursor_history = False - self.update_cursorpos_actions() - - @Slot() - def go_to_previous_cursor_position(self): - self.__ignore_cursor_history = True - self.switch_to_plugin() - self.__move_cursor_position(-1) - - @Slot() - def go_to_next_cursor_position(self): - self.__ignore_cursor_history = True - self.switch_to_plugin() - self.__move_cursor_position(1) - - @Slot() - def go_to_line(self, line=None): - """Open 'go to line' dialog""" - if isinstance(line, bool): - line = None - editorstack = self.get_current_editorstack() - if editorstack is not None: - editorstack.go_to_line(line) - - @Slot() - def set_or_clear_breakpoint(self): - """Set/Clear breakpoint""" - editorstack = self.get_current_editorstack() - if editorstack is not None: - self.switch_to_plugin() - editorstack.set_or_clear_breakpoint() - - @Slot() - def set_or_edit_conditional_breakpoint(self): - """Set/Edit conditional breakpoint""" - editorstack = self.get_current_editorstack() - if editorstack is not None: - self.switch_to_plugin() - editorstack.set_or_edit_conditional_breakpoint() - - @Slot() - def clear_all_breakpoints(self): - """Clear breakpoints in all files""" - self.switch_to_plugin() - clear_all_breakpoints() - self.breakpoints_saved.emit() - editorstack = self.get_current_editorstack() - if editorstack is not None: - for data in editorstack.data: - data.editor.debugger.clear_breakpoints() - self.refresh_plugin() - - def clear_breakpoint(self, filename, lineno): - """Remove a single breakpoint""" - clear_breakpoint(filename, lineno) - self.breakpoints_saved.emit() - editorstack = self.get_current_editorstack() - if editorstack is not None: - index = self.is_file_opened(filename) - if index is not None: - editorstack.data[index].editor.debugger.toogle_breakpoint( - lineno) - - def stop_debugging(self): - """Stop debugging""" - ipyconsole = self.main.get_plugin(Plugins.IPythonConsole, error=False) - if ipyconsole: - ipyconsole.stop_debugging() - - def debug_command(self, command): - """Debug actions""" - self.switch_to_plugin() - ipyconsole = self.main.get_plugin(Plugins.IPythonConsole, error=False) - if ipyconsole: - ipyconsole.pdb_execute_command(command) - ipyconsole.switch_to_plugin() - - # ----- Handlers for the IPython Console kernels - def _get_editorstack(self): - """ - Get the current editorstack. - - Raises an exception in case no editorstack is found - """ - editorstack = self.get_current_editorstack() - if editorstack is None: - raise RuntimeError('No editorstack found.') - - return editorstack - - def _get_editor(self, filename): - """Get editor for filename and set it as the current editor.""" - editorstack = self._get_editorstack() - if editorstack is None: - return None - - if not filename: - return None - - index = editorstack.has_filename(filename) - if index is None: - return None - - return editorstack.data[index].editor - - def handle_run_cell(self, cell_name, filename): - """ - Get cell code from cell name and file name. - """ - editorstack = self._get_editorstack() - editor = self._get_editor(filename) - - if editor is None: - raise RuntimeError( - "File {} not open in the editor".format(filename)) - - editorstack.last_cell_call = (filename, cell_name) - - # The file is open, load code from editor - return editor.get_cell_code(cell_name) - - def handle_cell_count(self, filename): - """Get number of cells in file to loop.""" - editor = self._get_editor(filename) - - if editor is None: - raise RuntimeError( - "File {} not open in the editor".format(filename)) - - # The file is open, get cell count from editor - return editor.get_cell_count() - - def handle_current_filename(self, filename): - """Get the current filename.""" - return self._get_editorstack().get_current_finfo().filename - - def handle_get_file_code(self, filename, save_all=True): - """ - Return the bytes that compose the file. - - Bytes are returned instead of str to support non utf-8 files. - """ - editorstack = self._get_editorstack() - if save_all and CONF.get( - 'editor', 'save_all_before_run', default=True): - editorstack.save_all(save_new_files=False) - editor = self._get_editor(filename) - - if editor is None: - # Load it from file instead - text, _enc = encoding.read(filename) - return text - - return editor.toPlainText() - - #------ Run Python script - @Slot() - def edit_run_configurations(self): - dialog = RunConfigDialog(self) - dialog.size_change.connect(lambda s: self.set_dialog_size(s)) - if self.dialog_size is not None: - dialog.resize(self.dialog_size) - fname = osp.abspath(self.get_current_filename()) - dialog.setup(fname) - if dialog.exec_(): - fname = dialog.file_to_run - if fname is not None: - self.load(fname) - self.run_file() - - @Slot() - def run_file(self, debug=False): - """Run script inside current interpreter or in a new one""" - editorstack = self.get_current_editorstack() - - editor = self.get_current_editor() - fname = osp.abspath(self.get_current_filename()) - - # Get fname's dirname before we escape the single and double - # quotes. Fixes spyder-ide/spyder#6771. - dirname = osp.dirname(fname) - - # Escape single and double quotes in fname and dirname. - # Fixes spyder-ide/spyder#2158. - fname = fname.replace("'", r"\'").replace('"', r'\"') - dirname = dirname.replace("'", r"\'").replace('"', r'\"') - - runconf = get_run_configuration(fname) - if runconf is None: - dialog = RunConfigOneDialog(self) - dialog.size_change.connect(lambda s: self.set_dialog_size(s)) - if self.dialog_size is not None: - dialog.resize(self.dialog_size) - dialog.setup(fname) - if CONF.get('run', 'open_at_least_once', - not running_under_pytest()): - # Open Run Config dialog at least once: the first time - # a script is ever run in Spyder, so that the user may - # see it at least once and be conscious that it exists - show_dlg = True - CONF.set('run', 'open_at_least_once', False) - else: - # Open Run Config dialog only - # if ALWAYS_OPEN_FIRST_RUN_OPTION option is enabled - show_dlg = CONF.get('run', ALWAYS_OPEN_FIRST_RUN_OPTION) - if show_dlg and not dialog.exec_(): - return - runconf = dialog.get_configuration() - - if runconf.default: - # use global run preferences settings - runconf = RunConfiguration() - - args = runconf.get_arguments() - python_args = runconf.get_python_arguments() - interact = runconf.interact - post_mortem = runconf.post_mortem - current = runconf.current - systerm = runconf.systerm - clear_namespace = runconf.clear_namespace - console_namespace = runconf.console_namespace - - if runconf.file_dir: - wdir = dirname - elif runconf.cw_dir: - wdir = '' - elif osp.isdir(runconf.dir): - wdir = runconf.dir - else: - wdir = '' - - python = True # Note: in the future, it may be useful to run - # something in a terminal instead of a Python interp. - self.__last_ec_exec = (fname, wdir, args, interact, debug, - python, python_args, current, systerm, - post_mortem, clear_namespace, - console_namespace) - self.re_run_file(save_new_files=False) - if not interact and not debug: - # If external console dockwidget is hidden, it will be - # raised in top-level and so focus will be given to the - # current external shell automatically - # (see SpyderPluginWidget.visibility_changed method) - editor.setFocus() - - def set_dialog_size(self, size): - self.dialog_size = size - - @Slot() - def debug_file(self): - """Debug current script""" - self.switch_to_plugin() - current_editor = self.get_current_editor() - if current_editor is not None: - current_editor.sig_debug_start.emit() - self.run_file(debug=True) - - @Slot() - def re_run_file(self, save_new_files=True): - """Re-run last script""" - if self.get_option('save_all_before_run'): - all_saved = self.save_all(save_new_files=save_new_files) - if all_saved is not None and not all_saved: - return - if self.__last_ec_exec is None: - return - (fname, wdir, args, interact, debug, - python, python_args, current, systerm, - post_mortem, clear_namespace, - console_namespace) = self.__last_ec_exec - if not systerm: - self.run_in_current_ipyclient.emit(fname, wdir, args, - debug, post_mortem, - current, clear_namespace, - console_namespace) - else: - self.main.open_external_console(fname, wdir, args, interact, - debug, python, python_args, - systerm, post_mortem) - - @Slot() - def run_selection(self): - """Run selection or current line in external console""" - editorstack = self.get_current_editorstack() - editorstack.run_selection() - - @Slot() - def run_to_line(self): - """Run all lines from beginning up to current line""" - editorstack = self.get_current_editorstack() - editorstack.run_to_line() - - @Slot() - def run_from_line(self): - """Run all lines from current line to end""" - editorstack = self.get_current_editorstack() - editorstack.run_from_line() - - @Slot() - def run_cell(self): - """Run current cell""" - editorstack = self.get_current_editorstack() - editorstack.run_cell() - - @Slot() - def run_cell_and_advance(self): - """Run current cell and advance to the next one""" - editorstack = self.get_current_editorstack() - editorstack.run_cell_and_advance() - - @Slot() - def debug_cell(self): - '''Debug Current cell.''' - editorstack = self.get_current_editorstack() - editorstack.debug_cell() - - @Slot() - def re_run_last_cell(self): - """Run last executed cell.""" - editorstack = self.get_current_editorstack() - editorstack.re_run_last_cell() - - # ------ Code bookmarks - @Slot(int) - def save_bookmark(self, slot_num): - """Save current line and position as bookmark.""" - bookmarks = CONF.get('editor', 'bookmarks') - editorstack = self.get_current_editorstack() - if slot_num in bookmarks: - filename, line_num, column = bookmarks[slot_num] - if osp.isfile(filename): - index = editorstack.has_filename(filename) - if index is not None: - block = (editorstack.tabs.widget(index).document() - .findBlockByNumber(line_num)) - block.userData().bookmarks.remove((slot_num, column)) - if editorstack is not None: - self.switch_to_plugin() - editorstack.set_bookmark(slot_num) - - @Slot(int) - def load_bookmark(self, slot_num): - """Set cursor to bookmarked file and position.""" - bookmarks = CONF.get('editor', 'bookmarks') - if slot_num in bookmarks: - filename, line_num, column = bookmarks[slot_num] - else: - return - if not osp.isfile(filename): - self.last_edit_cursor_pos = None - return - self.load(filename) - editor = self.get_current_editor() - if line_num < editor.document().lineCount(): - linelength = len(editor.document() - .findBlockByNumber(line_num).text()) - if column <= linelength: - editor.go_to_line(line_num + 1, column) - else: - # Last column - editor.go_to_line(line_num + 1, linelength) - - #------ Zoom in/out/reset - def zoom(self, factor): - """Zoom in/out/reset""" - editor = self.get_current_editorstack().get_current_editor() - if factor == 0: - font = self.get_font() - editor.set_font(font) - else: - font = editor.font() - size = font.pointSize() + factor - if size > 0: - font.setPointSize(size) - editor.set_font(font) - editor.update_tab_stop_width_spaces() - - #------ Options - def apply_plugin_settings(self, options): - """Apply configuration file's plugin settings""" - if self.editorstacks is not None: - # --- syntax highlight and text rendering settings - currentline_n = 'highlight_current_line' - currentline_o = self.get_option(currentline_n) - currentcell_n = 'highlight_current_cell' - currentcell_o = self.get_option(currentcell_n) - occurrence_n = 'occurrence_highlighting' - occurrence_o = self.get_option(occurrence_n) - occurrence_timeout_n = 'occurrence_highlighting/timeout' - occurrence_timeout_o = self.get_option(occurrence_timeout_n) - focus_to_editor_n = 'focus_to_editor' - focus_to_editor_o = self.get_option(focus_to_editor_n) - - for editorstack in self.editorstacks: - if currentline_n in options: - editorstack.set_highlight_current_line_enabled( - currentline_o) - if currentcell_n in options: - editorstack.set_highlight_current_cell_enabled( - currentcell_o) - if occurrence_n in options: - editorstack.set_occurrence_highlighting_enabled(occurrence_o) - if occurrence_timeout_n in options: - editorstack.set_occurrence_highlighting_timeout( - occurrence_timeout_o) - if focus_to_editor_n in options: - editorstack.set_focus_to_editor(focus_to_editor_o) - - # --- everything else - tabbar_n = 'show_tab_bar' - tabbar_o = self.get_option(tabbar_n) - classfuncdropdown_n = 'show_class_func_dropdown' - classfuncdropdown_o = self.get_option(classfuncdropdown_n) - linenb_n = 'line_numbers' - linenb_o = self.get_option(linenb_n) - blanks_n = 'blank_spaces' - blanks_o = self.get_option(blanks_n) - scrollpastend_n = 'scroll_past_end' - scrollpastend_o = self.get_option(scrollpastend_n) - wrap_n = 'wrap' - wrap_o = self.get_option(wrap_n) - indentguides_n = 'indent_guides' - indentguides_o = self.get_option(indentguides_n) - codefolding_n = 'code_folding' - codefolding_o = self.get_option(codefolding_n) - tabindent_n = 'tab_always_indent' - tabindent_o = self.get_option(tabindent_n) - stripindent_n = 'strip_trailing_spaces_on_modify' - stripindent_o = self.get_option(stripindent_n) - ibackspace_n = 'intelligent_backspace' - ibackspace_o = self.get_option(ibackspace_n) - removetrail_n = 'always_remove_trailing_spaces' - removetrail_o = self.get_option(removetrail_n) - add_newline_n = 'add_newline' - add_newline_o = self.get_option(add_newline_n) - removetrail_newlines_n = 'always_remove_trailing_newlines' - removetrail_newlines_o = self.get_option(removetrail_newlines_n) - converteol_n = 'convert_eol_on_save' - converteol_o = self.get_option(converteol_n) - converteolto_n = 'convert_eol_on_save_to' - converteolto_o = self.get_option(converteolto_n) - runcellcopy_n = 'run_cell_copy' - runcellcopy_o = self.get_option(runcellcopy_n) - closepar_n = 'close_parentheses' - closepar_o = self.get_option(closepar_n) - close_quotes_n = 'close_quotes' - close_quotes_o = self.get_option(close_quotes_n) - add_colons_n = 'add_colons' - add_colons_o = self.get_option(add_colons_n) - autounindent_n = 'auto_unindent' - autounindent_o = self.get_option(autounindent_n) - indent_chars_n = 'indent_chars' - indent_chars_o = self.get_option(indent_chars_n) - tab_stop_width_spaces_n = 'tab_stop_width_spaces' - tab_stop_width_spaces_o = self.get_option(tab_stop_width_spaces_n) - help_n = 'connect_to_oi' - help_o = CONF.get('help', 'connect/editor') - todo_n = 'todo_list' - todo_o = self.get_option(todo_n) - - finfo = self.get_current_finfo() - - for editorstack in self.editorstacks: - # Checkable options - if blanks_n in options: - editorstack.set_blanks_enabled(blanks_o) - if scrollpastend_n in options: - editorstack.set_scrollpastend_enabled(scrollpastend_o) - if indentguides_n in options: - editorstack.set_indent_guides(indentguides_o) - if codefolding_n in options: - editorstack.set_code_folding_enabled(codefolding_o) - if classfuncdropdown_n in options: - editorstack.set_classfunc_dropdown_visible( - classfuncdropdown_o) - if tabbar_n in options: - editorstack.set_tabbar_visible(tabbar_o) - if linenb_n in options: - editorstack.set_linenumbers_enabled(linenb_o, - current_finfo=finfo) - if wrap_n in options: - editorstack.set_wrap_enabled(wrap_o) - if tabindent_n in options: - editorstack.set_tabmode_enabled(tabindent_o) - if stripindent_n in options: - editorstack.set_stripmode_enabled(stripindent_o) - if ibackspace_n in options: - editorstack.set_intelligent_backspace_enabled(ibackspace_o) - if removetrail_n in options: - editorstack.set_always_remove_trailing_spaces(removetrail_o) - if add_newline_n in options: - editorstack.set_add_newline(add_newline_o) - if removetrail_newlines_n in options: - editorstack.set_remove_trailing_newlines( - removetrail_newlines_o) - if converteol_n in options: - editorstack.set_convert_eol_on_save(converteol_o) - if converteolto_n in options: - editorstack.set_convert_eol_on_save_to(converteolto_o) - if runcellcopy_n in options: - editorstack.set_run_cell_copy(runcellcopy_o) - if closepar_n in options: - editorstack.set_close_parentheses_enabled(closepar_o) - if close_quotes_n in options: - editorstack.set_close_quotes_enabled(close_quotes_o) - if add_colons_n in options: - editorstack.set_add_colons_enabled(add_colons_o) - if autounindent_n in options: - editorstack.set_auto_unindent_enabled(autounindent_o) - if indent_chars_n in options: - editorstack.set_indent_chars(indent_chars_o) - if tab_stop_width_spaces_n in options: - editorstack.set_tab_stop_width_spaces(tab_stop_width_spaces_o) - if help_n in options: - editorstack.set_help_enabled(help_o) - if todo_n in options: - editorstack.set_todolist_enabled(todo_o, - current_finfo=finfo) - - for name, action in self.checkable_actions.items(): - if name in options: - # Avoid triggering the action when this action changes state - action.blockSignals(True) - state = self.get_option(name) - action.setChecked(state) - action.blockSignals(False) - # See: spyder-ide/spyder#9915 - - # Multiply by 1000 to convert seconds to milliseconds - self.autosave.interval = ( - self.get_option('autosave_interval') * 1000) - self.autosave.enabled = self.get_option('autosave_enabled') - - # We must update the current editor after the others: - # (otherwise, code analysis buttons state would correspond to the - # last editor instead of showing the one of the current editor) - if finfo is not None: - if todo_n in options and todo_o: - finfo.run_todo_finder() - - @on_conf_change(option='edge_line') - def set_edgeline_enabled(self, value): - if self.editorstacks is not None: - logger.debug(f"Set edge line to {value}") - for editorstack in self.editorstacks: - editorstack.set_edgeline_enabled(value) - - @on_conf_change( - option=('provider_configuration', 'lsp', 'values', - 'pycodestyle/max_line_length'), - section='completions' - ) - def set_edgeline_columns(self, value): - if self.editorstacks is not None: - logger.debug(f"Set edge line columns to {value}") - for editorstack in self.editorstacks: - editorstack.set_edgeline_columns(value) - - @on_conf_change(option='enable_code_snippets', section='completions') - def set_code_snippets_enabled(self, value): - if self.editorstacks is not None: - logger.debug(f"Set code snippets to {value}") - for editorstack in self.editorstacks: - editorstack.set_code_snippets_enabled(value) - - @on_conf_change(option='automatic_completions') - def set_automatic_completions_enabled(self, value): - if self.editorstacks is not None: - logger.debug(f"Set automatic completions to {value}") - for editorstack in self.editorstacks: - editorstack.set_automatic_completions_enabled(value) - - @on_conf_change(option='automatic_completions_after_chars') - def set_automatic_completions_after_chars(self, value): - if self.editorstacks is not None: - logger.debug(f"Set chars for automatic completions to {value}") - for editorstack in self.editorstacks: - editorstack.set_automatic_completions_after_chars(value) - - @on_conf_change(option='automatic_completions_after_ms') - def set_automatic_completions_after_ms(self, value): - if self.editorstacks is not None: - logger.debug(f"Set automatic completions after {value} ms") - for editorstack in self.editorstacks: - editorstack.set_automatic_completions_after_ms(value) - - @on_conf_change(option='completions_hint') - def set_completions_hint_enabled(self, value): - if self.editorstacks is not None: - logger.debug(f"Set completions hint to {value}") - for editorstack in self.editorstacks: - editorstack.set_completions_hint_enabled(value) - - @on_conf_change(option='completions_hint_after_ms') - def set_completions_hint_after_ms(self, value): - if self.editorstacks is not None: - logger.debug(f"Set completions hint after {value} ms") - for editorstack in self.editorstacks: - editorstack.set_completions_hint_after_ms(value) - - @on_conf_change( - option=('provider_configuration', 'lsp', 'values', - 'enable_hover_hints'), - section='completions' - ) - def set_hover_hints_enabled(self, value): - if self.editorstacks is not None: - logger.debug(f"Set hover hints to {value}") - for editorstack in self.editorstacks: - editorstack.set_hover_hints_enabled(value) - - @on_conf_change( - option=('provider_configuration', 'lsp', 'values', 'format_on_save'), - section='completions' - ) - def set_format_on_save(self, value): - if self.editorstacks is not None: - logger.debug(f"Set format on save to {value}") - for editorstack in self.editorstacks: - editorstack.set_format_on_save(value) - - @on_conf_change(option='underline_errors') - def set_underline_errors_enabled(self, value): - if self.editorstacks is not None: - logger.debug(f"Set underline errors to {value}") - for editorstack in self.editorstacks: - editorstack.set_underline_errors_enabled(value) - - @on_conf_change(option='selected', section='appearance') - def set_color_scheme(self, value): - if self.editorstacks is not None: - logger.debug(f"Set color scheme to {value}") - for editorstack in self.editorstacks: - editorstack.set_color_scheme(value) - - # --- Open files - def get_open_filenames(self): - """Get the list of open files in the current stack""" - editorstack = self.editorstacks[0] - filenames = [] - filenames += [finfo.filename for finfo in editorstack.data] - return filenames - - def set_open_filenames(self): - """ - Set the recent opened files on editor based on active project. - - If no project is active, then editor filenames are saved, otherwise - the opened filenames are stored in the project config info. - """ - if self.projects is not None: - if not self.projects.get_active_project(): - filenames = self.get_open_filenames() - self.set_option('filenames', filenames) - - def setup_open_files(self, close_previous_files=True): - """ - Open the list of saved files per project. - - Also open any files that the user selected in the recovery dialog. - """ - self.set_create_new_file_if_empty(False) - active_project_path = None - if self.projects is not None: - active_project_path = self.projects.get_active_project_path() - - if active_project_path: - filenames = self.projects.get_project_filenames() - else: - filenames = self.get_option('filenames', default=[]) - - if close_previous_files: - self.close_all_files() - - all_filenames = self.autosave.recover_files_to_open + filenames - if all_filenames and any([osp.isfile(f) for f in all_filenames]): - layout = self.get_option('layout_settings', None) - # Check if no saved layout settings exist, e.g. clean prefs file. - # If not, load with default focus/layout, to fix - # spyder-ide/spyder#8458. - if layout: - is_vertical, cfname, clines = layout.get('splitsettings')[0] - # Check that a value for current line exist for each filename - # in the available settings. See spyder-ide/spyder#12201 - if cfname in filenames and len(filenames) == len(clines): - index = filenames.index(cfname) - # First we load the last focused file. - self.load(filenames[index], goto=clines[index], set_focus=True) - # Then we load the files located to the left of the last - # focused file in the tabbar, while keeping the focus on - # the last focused file. - if index > 0: - self.load(filenames[index::-1], goto=clines[index::-1], - set_focus=False, add_where='start') - # Then we load the files located to the right of the last - # focused file in the tabbar, while keeping the focus on - # the last focused file. - if index < (len(filenames) - 1): - self.load(filenames[index+1:], goto=clines[index:], - set_focus=False, add_where='end') - # Finally we load any recovered files at the end of the tabbar, - # while keeping focus on the last focused file. - if self.autosave.recover_files_to_open: - self.load(self.autosave.recover_files_to_open, - set_focus=False, add_where='end') - else: - if filenames: - self.load(filenames, goto=clines) - if self.autosave.recover_files_to_open: - self.load(self.autosave.recover_files_to_open) - else: - if filenames: - self.load(filenames) - if self.autosave.recover_files_to_open: - self.load(self.autosave.recover_files_to_open) - - if self.__first_open_files_setup: - self.__first_open_files_setup = False - if layout is not None: - self.editorsplitter.set_layout_settings( - layout, - dont_goto=filenames[0]) - win_layout = self.get_option('windows_layout_settings', []) - if win_layout: - for layout_settings in win_layout: - self.editorwindows_to_be_created.append( - layout_settings) - self.set_last_focused_editorstack(self, self.editorstacks[0]) - - # This is necessary to update the statusbar widgets after files - # have been loaded. - editorstack = self.get_current_editorstack() - if editorstack: - self.get_current_editorstack().refresh() - else: - self.__load_temp_file() - self.set_create_new_file_if_empty(True) - self.sig_open_files_finished.emit() - - def save_open_files(self): - """Save the list of open files""" - self.set_option('filenames', self.get_open_filenames()) - - def set_create_new_file_if_empty(self, value): - """Change the value of create_new_file_if_empty""" - for editorstack in self.editorstacks: - editorstack.create_new_file_if_empty = value - - # --- File Menu actions (Mac only) - @Slot() - def go_to_next_file(self): - """Switch to next file tab on the current editor stack.""" - editorstack = self.get_current_editorstack() - editorstack.tabs.tab_navigate(+1) - - @Slot() - def go_to_previous_file(self): - """Switch to previous file tab on the current editor stack.""" - editorstack = self.get_current_editorstack() - editorstack.tabs.tab_navigate(-1) - - def set_current_project_path(self, root_path=None): - """ - Set the current active project root path. - - Parameters - ---------- - root_path: str or None, optional - Path to current project root path. Default is None. - """ - for editorstack in self.editorstacks: - editorstack.set_current_project_path(root_path) - - def register_panel(self, panel_class, *args, position=Panel.Position.LEFT, - **kwargs): - """Register a panel in all the editorstacks in the given position.""" - for editorstack in self.editorstacks: - editorstack.register_panel( - panel_class, *args, position=position, **kwargs) - - # TODO: To be updated after migration - def on_mainwindow_visible(self): - return +# -*- coding: utf-8 -*- +# +# Copyright © Spyder Project Contributors +# Licensed under the terms of the MIT License +# (see spyder/__init__.py for details) + +"""Editor Plugin""" + +# pylint: disable=C0103 +# pylint: disable=R0903 +# pylint: disable=R0911 +# pylint: disable=R0201 + +# Standard library imports +import logging +import os +import os.path as osp +import re +import sys +import time + +# Third party imports +from qtpy.compat import from_qvariant, getopenfilenames, to_qvariant +from qtpy.QtCore import QByteArray, Qt, Signal, Slot, QDir +from qtpy.QtGui import QTextCursor +from qtpy.QtPrintSupport import (QAbstractPrintDialog, QPrintDialog, QPrinter, + QPrintPreviewDialog) +from qtpy.QtWidgets import (QAction, QActionGroup, QApplication, QDialog, + QFileDialog, QInputDialog, QMenu, QSplitter, + QToolBar, QVBoxLayout, QWidget) + +# Local imports +from spyder.api.config.decorators import on_conf_change +from spyder.api.config.mixins import SpyderConfigurationObserver +from spyder.api.panel import Panel +from spyder.api.plugins import Plugins, SpyderPluginWidget +from spyder.config.base import _, get_conf_path, running_under_pytest +from spyder.config.manager import CONF +from spyder.config.utils import (get_edit_filetypes, get_edit_filters, + get_filter) +from spyder.py3compat import PY2, qbytearray_to_str, to_text_string +from spyder.utils import encoding, programs, sourcecode +from spyder.utils.icon_manager import ima +from spyder.utils.qthelpers import create_action, add_actions, MENU_SEPARATOR +from spyder.utils.misc import getcwd_or_home +from spyder.widgets.findreplace import FindReplace +from spyder.plugins.editor.confpage import EditorConfigPage +from spyder.plugins.editor.utils.autosave import AutosaveForPlugin +from spyder.plugins.editor.utils.switcher import EditorSwitcherManager +from spyder.plugins.editor.widgets.codeeditor_widgets import Printer +from spyder.plugins.editor.widgets.editor import (EditorMainWindow, + EditorSplitter, + EditorStack,) +from spyder.plugins.editor.widgets.codeeditor import CodeEditor +from spyder.plugins.editor.utils.bookmarks import (load_bookmarks, + save_bookmarks) +from spyder.plugins.editor.utils.debugger import (clear_all_breakpoints, + clear_breakpoint) +from spyder.plugins.editor.widgets.status import (CursorPositionStatus, + EncodingStatus, EOLStatus, + ReadWriteStatus, VCSStatus) +from spyder.plugins.run.widgets import (ALWAYS_OPEN_FIRST_RUN_OPTION, + get_run_configuration, RunConfigDialog, + RunConfiguration, RunConfigOneDialog) +from spyder.plugins.mainmenu.api import ApplicationMenus +from spyder.widgets.simplecodeeditor import SimpleCodeEditor + + +logger = logging.getLogger(__name__) + + +class Editor(SpyderPluginWidget, SpyderConfigurationObserver): + """ + Multi-file Editor widget + """ + CONF_SECTION = 'editor' + CONFIGWIDGET_CLASS = EditorConfigPage + CONF_FILE = False + TEMPFILE_PATH = get_conf_path('temp.py') + TEMPLATE_PATH = get_conf_path('template.py') + DISABLE_ACTIONS_WHEN_HIDDEN = False # SpyderPluginWidget class attribute + + # This is required for the new API + NAME = 'editor' + REQUIRES = [Plugins.Console] + OPTIONAL = [Plugins.Completions, Plugins.OutlineExplorer] + + # Signals + run_in_current_ipyclient = Signal(str, str, str, + bool, bool, bool, bool, bool) + run_cell_in_ipyclient = Signal(str, object, str, bool, bool) + debug_cell_in_ipyclient = Signal(str, object, str, bool, bool) + exec_in_extconsole = Signal(str, bool) + redirect_stdio = Signal(bool) + + sig_dir_opened = Signal(str) + """ + This signal is emitted when the editor changes the current directory. + + Parameters + ---------- + new_working_directory: str + The new working directory path. + + Notes + ----- + This option is available on the options menu of the editor plugin + """ + + breakpoints_saved = Signal() + + sig_file_opened_closed_or_updated = Signal(str, str) + """ + This signal is emitted when a file is opened, closed or updated, + including switching among files. + + Parameters + ---------- + filename: str + Name of the file that was opened, closed or updated. + language: str + Name of the programming language of the file that was opened, + closed or updated. + """ + + sig_file_debug_message_requested = Signal() + + # This signal is fired for any focus change among all editor stacks + sig_editor_focus_changed = Signal() + + sig_help_requested = Signal(dict) + """ + This signal is emitted to request help on a given object `name`. + + Parameters + ---------- + help_data: dict + Dictionary required by the Help pane to render a docstring. + + Examples + -------- + >>> help_data = { + 'obj_text': str, + 'name': str, + 'argspec': str, + 'note': str, + 'docstring': str, + 'force_refresh': bool, + 'path': str, + } + + See Also + -------- + :py:meth:spyder.plugins.editor.widgets.editor.EditorStack.send_to_help + """ + + sig_open_files_finished = Signal() + """ + This signal is emitted when the editor finished to open files. + """ + + def __init__(self, parent, ignore_last_opened_files=False): + SpyderPluginWidget.__init__(self, parent) + + self.__set_eol_chars = True + + # Creating template if it doesn't already exist + if not osp.isfile(self.TEMPLATE_PATH): + if os.name == "nt": + shebang = [] + else: + shebang = ['#!/usr/bin/env python' + ('2' if PY2 else '3')] + header = shebang + [ + '# -*- coding: utf-8 -*-', + '"""', 'Created on %(date)s', '', + '@author: %(username)s', '"""', '', ''] + try: + encoding.write(os.linesep.join(header), self.TEMPLATE_PATH, + 'utf-8') + except EnvironmentError: + pass + + self.projects = None + self.outlineexplorer = None + + self.file_dependent_actions = [] + self.pythonfile_dependent_actions = [] + self.dock_toolbar_actions = None + self.edit_menu_actions = None #XXX: find another way to notify Spyder + self.stack_menu_actions = None + self.checkable_actions = {} + + self.__first_open_files_setup = True + self.editorstacks = [] + self.last_focused_editorstack = {} + self.editorwindows = [] + self.editorwindows_to_be_created = [] + self.toolbar_list = None + self.menu_list = None + + # We need to call this here to create self.dock_toolbar_actions, + # which is used below. + self._setup() + self.options_button.hide() + + # Configuration dialog size + self.dialog_size = None + + self.vcs_status = VCSStatus(self) + self.cursorpos_status = CursorPositionStatus(self) + self.encoding_status = EncodingStatus(self) + self.eol_status = EOLStatus(self) + self.readwrite_status = ReadWriteStatus(self) + + # TODO: temporal fix while editor uses new API + statusbar = self.main.get_plugin(Plugins.StatusBar, error=False) + if statusbar: + statusbar.add_status_widget(self.readwrite_status) + statusbar.add_status_widget(self.eol_status) + statusbar.add_status_widget(self.encoding_status) + statusbar.add_status_widget(self.cursorpos_status) + statusbar.add_status_widget(self.vcs_status) + + layout = QVBoxLayout() + self.dock_toolbar = QToolBar(self) + add_actions(self.dock_toolbar, self.dock_toolbar_actions) + layout.addWidget(self.dock_toolbar) + + self.last_edit_cursor_pos = None + self.cursor_undo_history = [] + self.cursor_redo_history = [] + self.__ignore_cursor_history = True + + # Completions setup + self.completion_capabilities = {} + + # Setup new windows: + self.main.all_actions_defined.connect(self.setup_other_windows) + + # Change module completions when PYTHONPATH changes + self.main.sig_pythonpath_changed.connect(self.set_path) + + # Find widget + self.find_widget = FindReplace(self, enable_replace=True) + self.find_widget.hide() + self.register_widget_shortcuts(self.find_widget) + + # Start autosave component + # (needs to be done before EditorSplitter) + self.autosave = AutosaveForPlugin(self) + self.autosave.try_recover_from_autosave() + + # Multiply by 1000 to convert seconds to milliseconds + self.autosave.interval = self.get_option('autosave_interval') * 1000 + self.autosave.enabled = self.get_option('autosave_enabled') + + # SimpleCodeEditor instance used to print file contents + self._print_editor = self._create_print_editor() + self._print_editor.hide() + + # Tabbed editor widget + Find/Replace widget + editor_widgets = QWidget(self) + editor_layout = QVBoxLayout() + editor_layout.setContentsMargins(0, 0, 0, 0) + editor_widgets.setLayout(editor_layout) + self.editorsplitter = EditorSplitter(self, self, + self.stack_menu_actions, first=True) + editor_layout.addWidget(self.editorsplitter) + editor_layout.addWidget(self.find_widget) + editor_layout.addWidget(self._print_editor) + + # Splitter: editor widgets (see above) + outline explorer + self.splitter = QSplitter(self) + self.splitter.setContentsMargins(0, 0, 0, 0) + self.splitter.addWidget(editor_widgets) + self.splitter.setStretchFactor(0, 5) + self.splitter.setStretchFactor(1, 1) + layout.addWidget(self.splitter) + self.setLayout(layout) + self.setFocusPolicy(Qt.ClickFocus) + + # Editor's splitter state + state = self.get_option('splitter_state', None) + if state is not None: + self.splitter.restoreState( QByteArray().fromHex( + str(state).encode('utf-8')) ) + + self.recent_files = self.get_option('recent_files', []) + self.untitled_num = 0 + + # Parameters of last file execution: + self.__last_ic_exec = None # internal console + self.__last_ec_exec = None # external console + + # File types and filters used by the Open dialog + self.edit_filetypes = None + self.edit_filters = None + + self.__ignore_cursor_history = False + current_editor = self.get_current_editor() + if current_editor is not None: + filename = self.get_current_filename() + cursor = current_editor.textCursor() + self.add_cursor_to_history(filename, cursor) + self.update_cursorpos_actions() + self.set_path() + + def set_projects(self, projects): + self.projects = projects + + @Slot() + def show_hide_projects(self): + if self.projects is not None: + dw = self.projects.dockwidget + if dw.isVisible(): + dw.hide() + else: + dw.show() + dw.raise_() + self.switch_to_plugin() + + def set_outlineexplorer(self, outlineexplorer): + self.outlineexplorer = outlineexplorer + for editorstack in self.editorstacks: + # Pass the OutlineExplorer widget to the stacks because they + # don't need the plugin + editorstack.set_outlineexplorer(self.outlineexplorer.get_widget()) + self.outlineexplorer.get_widget().edit_goto.connect( + lambda filenames, goto, word: + self.load(filenames=filenames, goto=goto, word=word, + editorwindow=self)) + self.outlineexplorer.get_widget().edit.connect( + lambda filenames: + self.load(filenames=filenames, editorwindow=self)) + + #------ Private API -------------------------------------------------------- + def restore_scrollbar_position(self): + """Restoring scrollbar position after main window is visible""" + # Widget is now visible, we may center cursor on top level editor: + try: + self.get_current_editor().centerCursor() + except AttributeError: + pass + + @Slot(dict) + def report_open_file(self, options): + """Report that a file was opened to the completion manager.""" + filename = options['filename'] + language = options['language'] + codeeditor = options['codeeditor'] + status = None + if self.main.get_plugin(Plugins.Completions, error=False): + status = ( + self.main.completions.start_completion_services_for_language( + language.lower())) + self.main.completions.register_file( + language.lower(), filename, codeeditor) + if status: + if language.lower() in self.completion_capabilities: + # When this condition is True, it means there's a server + # that can provide completion services for this file. + codeeditor.register_completion_capabilities( + self.completion_capabilities[language.lower()]) + codeeditor.start_completion_services() + elif self.main.completions.is_fallback_only(language.lower()): + # This is required to use fallback completions for files + # without a language server. + codeeditor.start_completion_services() + else: + if codeeditor.language == language.lower(): + logger.debug('Setting {0} completions off'.format(filename)) + codeeditor.completions_available = False + + @Slot(dict, str) + def register_completion_capabilities(self, capabilities, language): + """ + Register completion server capabilities in all editorstacks. + + Parameters + ---------- + capabilities: dict + Capabilities supported by a language server. + language: str + Programming language for the language server (it has to be + in small caps). + """ + logger.debug( + 'Completion server capabilities for {!s} are: {!r}'.format( + language, capabilities) + ) + + # This is required to start workspace before completion + # services when Spyder starts with an open project. + # TODO: Find a better solution for it in the future!! + projects = self.main.get_plugin(Plugins.Projects, error=False) + if projects: + projects.start_workspace_services() + + self.completion_capabilities[language] = dict(capabilities) + for editorstack in self.editorstacks: + editorstack.register_completion_capabilities( + capabilities, language) + + self.start_completion_services(language) + + def start_completion_services(self, language): + """Notify all editorstacks about LSP server availability.""" + for editorstack in self.editorstacks: + editorstack.start_completion_services(language) + + def stop_completion_services(self, language): + """Notify all editorstacks about LSP server unavailability.""" + for editorstack in self.editorstacks: + editorstack.stop_completion_services(language) + + def send_completion_request(self, language, request, params): + logger.debug("Perform request {0} for: {1}".format( + request, params['file'])) + try: + self.main.completions.send_request(language, request, params) + except AttributeError: + # Completions was closed + pass + + @Slot(str, tuple, dict) + def _rpc_call(self, method, args, kwargs): + meth = getattr(self, method) + meth(*args, **kwargs) + + #------ SpyderPluginWidget API --------------------------------------------- + @staticmethod + def get_plugin_title(): + """Return widget title""" + # TODO: This is a temporary measure to get the title of this plugin + # without creating an instance + title = _('Editor') + return title + + def get_plugin_icon(self): + """Return widget icon.""" + return ima.icon('edit') + + def get_focus_widget(self): + """ + Return the widget to give focus to. + + This happens when plugin's dockwidget is raised on top-level. + """ + return self.get_current_editor() + + def _visibility_changed(self, enable): + """DockWidget visibility has changed""" + SpyderPluginWidget._visibility_changed(self, enable) + if self.dockwidget is None: + return + if self.dockwidget.isWindow(): + self.dock_toolbar.show() + else: + self.dock_toolbar.hide() + if enable: + self.refresh_plugin() + self.sig_update_plugin_title.emit() + + def refresh_plugin(self): + """Refresh editor plugin""" + editorstack = self.get_current_editorstack() + editorstack.refresh() + self.refresh_save_all_action() + + def closing_plugin(self, cancelable=False): + """Perform actions before parent main window is closed""" + state = self.splitter.saveState() + self.set_option('splitter_state', qbytearray_to_str(state)) + editorstack = self.editorstacks[0] + + active_project_path = None + if self.projects is not None: + active_project_path = self.projects.get_active_project_path() + if not active_project_path: + self.set_open_filenames() + else: + self.projects.set_project_filenames( + [finfo.filename for finfo in editorstack.data]) + + self.set_option('layout_settings', + self.editorsplitter.get_layout_settings()) + self.set_option('windows_layout_settings', + [win.get_layout_settings() for win in self.editorwindows]) +# self.set_option('filenames', filenames) + self.set_option('recent_files', self.recent_files) + + # Stop autosave timer before closing windows + self.autosave.stop_autosave_timer() + + try: + if not editorstack.save_if_changed(cancelable) and cancelable: + return False + else: + for win in self.editorwindows[:]: + win.close() + return True + except IndexError: + return True + + def get_plugin_actions(self): + """Return a list of actions related to plugin""" + # ---- File menu and toolbar ---- + self.new_action = create_action( + self, + _("&New file..."), + icon=ima.icon('filenew'), tip=_("New file"), + triggered=self.new, + context=Qt.WidgetShortcut + ) + self.register_shortcut(self.new_action, context="Editor", + name="New file", add_shortcut_to_tip=True) + + self.open_last_closed_action = create_action( + self, + _("O&pen last closed"), + tip=_("Open last closed"), + triggered=self.open_last_closed + ) + self.register_shortcut(self.open_last_closed_action, context="Editor", + name="Open last closed") + + self.open_action = create_action(self, _("&Open..."), + icon=ima.icon('fileopen'), tip=_("Open file"), + triggered=self.load, + context=Qt.WidgetShortcut) + self.register_shortcut(self.open_action, context="Editor", + name="Open file", add_shortcut_to_tip=True) + + self.revert_action = create_action(self, _("&Revert"), + icon=ima.icon('revert'), tip=_("Revert file from disk"), + triggered=self.revert) + + self.save_action = create_action(self, _("&Save"), + icon=ima.icon('filesave'), tip=_("Save file"), + triggered=self.save, + context=Qt.WidgetShortcut) + self.register_shortcut(self.save_action, context="Editor", + name="Save file", add_shortcut_to_tip=True) + + self.save_all_action = create_action(self, _("Sav&e all"), + icon=ima.icon('save_all'), tip=_("Save all files"), + triggered=self.save_all, + context=Qt.WidgetShortcut) + self.register_shortcut(self.save_all_action, context="Editor", + name="Save all", add_shortcut_to_tip=True) + + save_as_action = create_action(self, _("Save &as..."), None, + ima.icon('filesaveas'), tip=_("Save current file as..."), + triggered=self.save_as, + context=Qt.WidgetShortcut) + self.register_shortcut(save_as_action, "Editor", "Save As") + + save_copy_as_action = create_action(self, _("Save copy as..."), None, + ima.icon('filesaveas'), _("Save copy of current file as..."), + triggered=self.save_copy_as) + + print_preview_action = create_action(self, _("Print preview..."), + tip=_("Print preview..."), triggered=self.print_preview) + self.print_action = create_action(self, _("&Print..."), + icon=ima.icon('print'), tip=_("Print current file..."), + triggered=self.print_file) + # Shortcut for close_action is defined in widgets/editor.py + self.close_action = create_action(self, _("&Close"), + icon=ima.icon('fileclose'), tip=_("Close current file"), + triggered=self.close_file) + + self.close_all_action = create_action(self, _("C&lose all"), + icon=ima.icon('filecloseall'), tip=_("Close all opened files"), + triggered=self.close_all_files, + context=Qt.WidgetShortcut) + self.register_shortcut(self.close_all_action, context="Editor", + name="Close all") + + # ---- Find menu and toolbar ---- + _text = _("&Find text") + find_action = create_action(self, _text, icon=ima.icon('find'), + tip=_text, triggered=self.find, + context=Qt.WidgetShortcut) + self.register_shortcut(find_action, context="find_replace", + name="Find text", add_shortcut_to_tip=True) + find_next_action = create_action(self, _("Find &next"), + icon=ima.icon('findnext'), + triggered=self.find_next, + context=Qt.WidgetShortcut) + self.register_shortcut(find_next_action, context="find_replace", + name="Find next") + find_previous_action = create_action(self, _("Find &previous"), + icon=ima.icon('findprevious'), + triggered=self.find_previous, + context=Qt.WidgetShortcut) + self.register_shortcut(find_previous_action, context="find_replace", + name="Find previous") + _text = _("&Replace text") + replace_action = create_action(self, _text, icon=ima.icon('replace'), + tip=_text, triggered=self.replace, + context=Qt.WidgetShortcut) + self.register_shortcut(replace_action, context="find_replace", + name="Replace text") + + # ---- Debug menu and toolbar ---- + set_clear_breakpoint_action = create_action(self, + _("Set/Clear breakpoint"), + icon=ima.icon('breakpoint_big'), + triggered=self.set_or_clear_breakpoint, + context=Qt.WidgetShortcut) + self.register_shortcut(set_clear_breakpoint_action, context="Editor", + name="Breakpoint") + + set_cond_breakpoint_action = create_action(self, + _("Set/Edit conditional breakpoint"), + icon=ima.icon('breakpoint_cond_big'), + triggered=self.set_or_edit_conditional_breakpoint, + context=Qt.WidgetShortcut) + self.register_shortcut(set_cond_breakpoint_action, context="Editor", + name="Conditional breakpoint") + + clear_all_breakpoints_action = create_action(self, + _('Clear breakpoints in all files'), + triggered=self.clear_all_breakpoints) + + # --- Debug toolbar --- + self.debug_action = create_action( + self, _("&Debug"), + icon=ima.icon('debug'), + tip=_("Debug file"), + triggered=self.debug_file) + self.register_shortcut(self.debug_action, context="_", name="Debug", + add_shortcut_to_tip=True) + + self.debug_next_action = create_action( + self, _("Step"), + icon=ima.icon('arrow-step-over'), tip=_("Run current line"), + triggered=lambda: self.debug_command("next")) + self.register_shortcut(self.debug_next_action, "_", "Debug Step Over", + add_shortcut_to_tip=True) + + self.debug_continue_action = create_action( + self, _("Continue"), + icon=ima.icon('arrow-continue'), + tip=_("Continue execution until next breakpoint"), + triggered=lambda: self.debug_command("continue")) + self.register_shortcut( + self.debug_continue_action, "_", "Debug Continue", + add_shortcut_to_tip=True) + + self.debug_step_action = create_action( + self, _("Step Into"), + icon=ima.icon('arrow-step-in'), + tip=_("Step into function or method of current line"), + triggered=lambda: self.debug_command("step")) + self.register_shortcut(self.debug_step_action, "_", "Debug Step Into", + add_shortcut_to_tip=True) + + self.debug_return_action = create_action( + self, _("Step Return"), + icon=ima.icon('arrow-step-out'), + tip=_("Run until current function or method returns"), + triggered=lambda: self.debug_command("return")) + self.register_shortcut( + self.debug_return_action, "_", "Debug Step Return", + add_shortcut_to_tip=True) + + self.debug_exit_action = create_action( + self, _("Stop"), + icon=ima.icon('stop_debug'), tip=_("Stop debugging"), + triggered=self.stop_debugging) + self.register_shortcut(self.debug_exit_action, "_", "Debug Exit", + add_shortcut_to_tip=True) + + # --- Run toolbar --- + run_action = create_action(self, _("&Run"), icon=ima.icon('run'), + tip=_("Run file"), + triggered=self.run_file) + self.register_shortcut(run_action, context="_", name="Run", + add_shortcut_to_tip=True) + + configure_action = create_action( + self, + _("&Configuration per file..."), + icon=ima.icon('run_settings'), + tip=_("Run settings"), + menurole=QAction.NoRole, + triggered=self.edit_run_configurations) + + self.register_shortcut(configure_action, context="_", + name="Configure", add_shortcut_to_tip=True) + + re_run_action = create_action(self, _("Re-run &last script"), + icon=ima.icon('run_again'), + tip=_("Run again last file"), + triggered=self.re_run_file) + self.register_shortcut(re_run_action, context="_", + name="Re-run last script", + add_shortcut_to_tip=True) + + run_selected_action = create_action(self, _("Run &selection or " + "current line"), + icon=ima.icon('run_selection'), + tip=_("Run selection or " + "current line"), + triggered=self.run_selection, + context=Qt.WidgetShortcut) + self.register_shortcut(run_selected_action, context="Editor", + name="Run selection", add_shortcut_to_tip=True) + + run_to_line_action = create_action(self, _("Run &to current line"), + tip=_("Run to current line"), + triggered=self.run_to_line, + context=Qt.WidgetShortcut) + self.register_shortcut(run_to_line_action, context="Editor", + name="Run to line", add_shortcut_to_tip=True) + + run_from_line_action = create_action(self, _("Run &from current line"), + tip=_("Run from current line"), + triggered=self.run_from_line, + context=Qt.WidgetShortcut) + self.register_shortcut(run_from_line_action, context="Editor", + name="Run from line", add_shortcut_to_tip=True) + + run_cell_action = create_action(self, + _("Run cell"), + icon=ima.icon('run_cell'), + tip=_("Run current cell \n" + "[Use #%% to create cells]"), + triggered=self.run_cell, + context=Qt.WidgetShortcut) + + self.register_shortcut(run_cell_action, context="Editor", + name="Run cell", add_shortcut_to_tip=True) + + run_cell_advance_action = create_action( + self, + _("Run cell and advance"), + icon=ima.icon('run_cell_advance'), + tip=_("Run current cell and go to the next one "), + triggered=self.run_cell_and_advance, + context=Qt.WidgetShortcut) + + self.register_shortcut(run_cell_advance_action, context="Editor", + name="Run cell and advance", + add_shortcut_to_tip=True) + + self.debug_cell_action = create_action( + self, + _("Debug cell"), + icon=ima.icon('debug_cell'), + tip=_("Debug current cell " + "(Alt+Shift+Enter)"), + triggered=self.debug_cell, + context=Qt.WidgetShortcut) + + self.register_shortcut(self.debug_cell_action, context="Editor", + name="Debug cell", + add_shortcut_to_tip=True) + + re_run_last_cell_action = create_action(self, + _("Re-run last cell"), + tip=_("Re run last cell "), + triggered=self.re_run_last_cell, + context=Qt.WidgetShortcut) + self.register_shortcut(re_run_last_cell_action, + context="Editor", + name='re-run last cell', + add_shortcut_to_tip=True) + + # --- Source code Toolbar --- + self.todo_list_action = create_action(self, + _("Show todo list"), icon=ima.icon('todo_list'), + tip=_("Show comments list (TODO/FIXME/XXX/HINT/TIP/@todo/" + "HACK/BUG/OPTIMIZE/!!!/???)"), + triggered=self.go_to_next_todo) + self.todo_menu = QMenu(self) + self.todo_menu.setStyleSheet("QMenu {menu-scrollable: 1;}") + self.todo_list_action.setMenu(self.todo_menu) + self.todo_menu.aboutToShow.connect(self.update_todo_menu) + + self.warning_list_action = create_action(self, + _("Show warning/error list"), icon=ima.icon('wng_list'), + tip=_("Show code analysis warnings/errors"), + triggered=self.go_to_next_warning) + self.warning_menu = QMenu(self) + self.warning_menu.setStyleSheet("QMenu {menu-scrollable: 1;}") + self.warning_list_action.setMenu(self.warning_menu) + self.warning_menu.aboutToShow.connect(self.update_warning_menu) + self.previous_warning_action = create_action(self, + _("Previous warning/error"), icon=ima.icon('prev_wng'), + tip=_("Go to previous code analysis warning/error"), + triggered=self.go_to_previous_warning, + context=Qt.WidgetShortcut) + self.register_shortcut(self.previous_warning_action, + context="Editor", + name="Previous warning", + add_shortcut_to_tip=True) + self.next_warning_action = create_action(self, + _("Next warning/error"), icon=ima.icon('next_wng'), + tip=_("Go to next code analysis warning/error"), + triggered=self.go_to_next_warning, + context=Qt.WidgetShortcut) + self.register_shortcut(self.next_warning_action, + context="Editor", + name="Next warning", + add_shortcut_to_tip=True) + + self.previous_edit_cursor_action = create_action(self, + _("Last edit location"), icon=ima.icon('last_edit_location'), + tip=_("Go to last edit location"), + triggered=self.go_to_last_edit_location, + context=Qt.WidgetShortcut) + self.register_shortcut(self.previous_edit_cursor_action, + context="Editor", + name="Last edit location", + add_shortcut_to_tip=True) + self.previous_cursor_action = create_action(self, + _("Previous cursor position"), icon=ima.icon('prev_cursor'), + tip=_("Go to previous cursor position"), + triggered=self.go_to_previous_cursor_position, + context=Qt.WidgetShortcut) + self.register_shortcut(self.previous_cursor_action, + context="Editor", + name="Previous cursor position", + add_shortcut_to_tip=True) + self.next_cursor_action = create_action(self, + _("Next cursor position"), icon=ima.icon('next_cursor'), + tip=_("Go to next cursor position"), + triggered=self.go_to_next_cursor_position, + context=Qt.WidgetShortcut) + self.register_shortcut(self.next_cursor_action, + context="Editor", + name="Next cursor position", + add_shortcut_to_tip=True) + + # --- Edit Toolbar --- + self.toggle_comment_action = create_action(self, + _("Comment")+"/"+_("Uncomment"), icon=ima.icon('comment'), + tip=_("Comment current line or selection"), + triggered=self.toggle_comment, context=Qt.WidgetShortcut) + self.register_shortcut(self.toggle_comment_action, context="Editor", + name="Toggle comment") + blockcomment_action = create_action(self, _("Add &block comment"), + tip=_("Add block comment around " + "current line or selection"), + triggered=self.blockcomment, context=Qt.WidgetShortcut) + self.register_shortcut(blockcomment_action, context="Editor", + name="Blockcomment") + unblockcomment_action = create_action(self, + _("R&emove block comment"), + tip = _("Remove comment block around " + "current line or selection"), + triggered=self.unblockcomment, context=Qt.WidgetShortcut) + self.register_shortcut(unblockcomment_action, context="Editor", + name="Unblockcomment") + + # ---------------------------------------------------------------------- + # The following action shortcuts are hard-coded in CodeEditor + # keyPressEvent handler (the shortcut is here only to inform user): + # (context=Qt.WidgetShortcut -> disable shortcut for other widgets) + self.indent_action = create_action(self, + _("Indent"), "Tab", icon=ima.icon('indent'), + tip=_("Indent current line or selection"), + triggered=self.indent, context=Qt.WidgetShortcut) + self.unindent_action = create_action(self, + _("Unindent"), "Shift+Tab", icon=ima.icon('unindent'), + tip=_("Unindent current line or selection"), + triggered=self.unindent, context=Qt.WidgetShortcut) + + self.text_uppercase_action = create_action(self, + _("Toggle Uppercase"), icon=ima.icon('toggle_uppercase'), + tip=_("Change to uppercase current line or selection"), + triggered=self.text_uppercase, context=Qt.WidgetShortcut) + self.register_shortcut(self.text_uppercase_action, context="Editor", + name="transform to uppercase") + + self.text_lowercase_action = create_action(self, + _("Toggle Lowercase"), icon=ima.icon('toggle_lowercase'), + tip=_("Change to lowercase current line or selection"), + triggered=self.text_lowercase, context=Qt.WidgetShortcut) + self.register_shortcut(self.text_lowercase_action, context="Editor", + name="transform to lowercase") + # ---------------------------------------------------------------------- + + self.win_eol_action = create_action( + self, + _("CRLF (Windows)"), + toggled=lambda checked: self.toggle_eol_chars('nt', checked) + ) + self.linux_eol_action = create_action( + self, + _("LF (Unix)"), + toggled=lambda checked: self.toggle_eol_chars('posix', checked) + ) + self.mac_eol_action = create_action( + self, + _("CR (macOS)"), + toggled=lambda checked: self.toggle_eol_chars('mac', checked) + ) + eol_action_group = QActionGroup(self) + eol_actions = (self.win_eol_action, self.linux_eol_action, + self.mac_eol_action) + add_actions(eol_action_group, eol_actions) + eol_menu = QMenu(_("Convert end-of-line characters"), self) + eol_menu.setObjectName('checkbox-padding') + add_actions(eol_menu, eol_actions) + + trailingspaces_action = create_action( + self, + _("Remove trailing spaces"), + triggered=self.remove_trailing_spaces) + + formatter = CONF.get( + 'completions', + ('provider_configuration', 'lsp', 'values', 'formatting'), + '') + self.formatting_action = create_action( + self, + _('Format file or selection with {0}').format( + formatter.capitalize()), + shortcut=CONF.get_shortcut('editor', 'autoformatting'), + context=Qt.WidgetShortcut, + triggered=self.format_document_or_selection) + self.formatting_action.setEnabled(False) + + # Checkable actions + showblanks_action = self._create_checkable_action( + _("Show blank spaces"), 'blank_spaces', 'set_blanks_enabled') + + scrollpastend_action = self._create_checkable_action( + _("Scroll past the end"), 'scroll_past_end', + 'set_scrollpastend_enabled') + + showindentguides_action = self._create_checkable_action( + _("Show indent guides"), 'indent_guides', 'set_indent_guides') + + showcodefolding_action = self._create_checkable_action( + _("Show code folding"), 'code_folding', 'set_code_folding_enabled') + + show_classfunc_dropdown_action = self._create_checkable_action( + _("Show selector for classes and functions"), + 'show_class_func_dropdown', 'set_classfunc_dropdown_visible') + + show_codestyle_warnings_action = self._create_checkable_action( + _("Show code style warnings"), 'pycodestyle',) + + show_docstring_warnings_action = self._create_checkable_action( + _("Show docstring style warnings"), 'pydocstyle') + + underline_errors = self._create_checkable_action( + _("Underline errors and warnings"), + 'underline_errors', 'set_underline_errors_enabled') + + self.checkable_actions = { + 'blank_spaces': showblanks_action, + 'scroll_past_end': scrollpastend_action, + 'indent_guides': showindentguides_action, + 'code_folding': showcodefolding_action, + 'show_class_func_dropdown': show_classfunc_dropdown_action, + 'pycodestyle': show_codestyle_warnings_action, + 'pydocstyle': show_docstring_warnings_action, + 'underline_errors': underline_errors} + + fixindentation_action = create_action(self, _("Fix indentation"), + tip=_("Replace tab characters by space characters"), + triggered=self.fix_indentation) + + gotoline_action = create_action(self, _("Go to line..."), + icon=ima.icon('gotoline'), + triggered=self.go_to_line, + context=Qt.WidgetShortcut) + self.register_shortcut(gotoline_action, context="Editor", + name="Go to line") + + workdir_action = create_action(self, + _("Set console working directory"), + icon=ima.icon('DirOpenIcon'), + tip=_("Set current console (and file explorer) working " + "directory to current script directory"), + triggered=self.__set_workdir) + + self.max_recent_action = create_action(self, + _("Maximum number of recent files..."), + triggered=self.change_max_recent_files) + self.clear_recent_action = create_action(self, + _("Clear this list"), tip=_("Clear recent files list"), + triggered=self.clear_recent_files) + + # Fixes spyder-ide/spyder#6055. + # See: https://bugreports.qt.io/browse/QTBUG-8596 + self.tab_navigation_actions = [] + if sys.platform == 'darwin': + self.go_to_next_file_action = create_action( + self, + _("Go to next file"), + shortcut=CONF.get_shortcut('editor', 'go to previous file'), + triggered=self.go_to_next_file, + ) + self.go_to_previous_file_action = create_action( + self, + _("Go to previous file"), + shortcut=CONF.get_shortcut('editor', 'go to next file'), + triggered=self.go_to_previous_file, + ) + self.register_shortcut( + self.go_to_next_file_action, + context="Editor", + name="Go to next file", + ) + self.register_shortcut( + self.go_to_previous_file_action, + context="Editor", + name="Go to previous file", + ) + self.tab_navigation_actions = [ + MENU_SEPARATOR, + self.go_to_previous_file_action, + self.go_to_next_file_action, + ] + + # ---- File menu/toolbar construction ---- + self.recent_file_menu = QMenu(_("Open &recent"), self) + self.recent_file_menu.aboutToShow.connect(self.update_recent_file_menu) + + from spyder.plugins.mainmenu.api import ( + ApplicationMenus, FileMenuSections) + # New Section + self.main.mainmenu.add_item_to_application_menu( + self.new_action, + menu_id=ApplicationMenus.File, + section=FileMenuSections.New, + before_section=FileMenuSections.Restart, + omit_id=True) + # Open section + open_actions = [ + self.open_action, + self.open_last_closed_action, + self.recent_file_menu, + ] + for open_action in open_actions: + self.main.mainmenu.add_item_to_application_menu( + open_action, + menu_id=ApplicationMenus.File, + section=FileMenuSections.Open, + before_section=FileMenuSections.Restart, + omit_id=True) + # Save section + save_actions = [ + self.save_action, + self.save_all_action, + save_as_action, + save_copy_as_action, + self.revert_action, + ] + for save_action in save_actions: + self.main.mainmenu.add_item_to_application_menu( + save_action, + menu_id=ApplicationMenus.File, + section=FileMenuSections.Save, + before_section=FileMenuSections.Restart, + omit_id=True) + # Print + print_actions = [ + print_preview_action, + self.print_action, + ] + for print_action in print_actions: + self.main.mainmenu.add_item_to_application_menu( + print_action, + menu_id=ApplicationMenus.File, + section=FileMenuSections.Print, + before_section=FileMenuSections.Restart, + omit_id=True) + # Close + close_actions = [ + self.close_action, + self.close_all_action + ] + for close_action in close_actions: + self.main.mainmenu.add_item_to_application_menu( + close_action, + menu_id=ApplicationMenus.File, + section=FileMenuSections.Close, + before_section=FileMenuSections.Restart, + omit_id=True) + # Navigation + if sys.platform == 'darwin': + self.main.mainmenu.add_item_to_application_menu( + self.tab_navigation_actions, + menu_id=ApplicationMenus.File, + section=FileMenuSections.Navigation, + before_section=FileMenuSections.Restart, + omit_id=True) + + file_toolbar_actions = ([self.new_action, self.open_action, + self.save_action, self.save_all_action] + + self.main.file_toolbar_actions) + + self.main.file_toolbar_actions += file_toolbar_actions + + # ---- Find menu/toolbar construction ---- + search_menu_actions = [find_action, + find_next_action, + find_previous_action, + replace_action, + gotoline_action] + + self.main.search_toolbar_actions = [find_action, + find_next_action, + replace_action] + + # ---- Edit menu/toolbar construction ---- + self.edit_menu_actions = [self.toggle_comment_action, + blockcomment_action, unblockcomment_action, + self.indent_action, self.unindent_action, + self.text_uppercase_action, + self.text_lowercase_action] + + # ---- Search menu/toolbar construction ---- + if not hasattr(self.main, 'search_menu_actions'): + # This list will not exist in the fast tests. + self.main.search_menu_actions = [] + + self.main.search_menu_actions = ( + search_menu_actions + self.main.search_menu_actions) + + # ---- Run menu/toolbar construction ---- + run_menu_actions = [run_action, run_cell_action, + run_cell_advance_action, + re_run_last_cell_action, MENU_SEPARATOR, + run_selected_action, run_to_line_action, + run_from_line_action, re_run_action, + configure_action, MENU_SEPARATOR] + self.main.run_menu_actions = ( + run_menu_actions + self.main.run_menu_actions) + run_toolbar_actions = [run_action, run_cell_action, + run_cell_advance_action, run_selected_action] + self.main.run_toolbar_actions += run_toolbar_actions + + # ---- Debug menu/toolbar construction ---- + debug_menu_actions = [ + self.debug_action, + self.debug_cell_action, + self.debug_next_action, + self.debug_step_action, + self.debug_return_action, + self.debug_continue_action, + self.debug_exit_action, + MENU_SEPARATOR, + set_clear_breakpoint_action, + set_cond_breakpoint_action, + clear_all_breakpoints_action, + ] + self.main.debug_menu_actions = ( + debug_menu_actions + self.main.debug_menu_actions) + debug_toolbar_actions = [ + self.debug_action, + self.debug_next_action, + self.debug_step_action, + self.debug_return_action, + self.debug_continue_action, + self.debug_exit_action + ] + self.main.debug_toolbar_actions += debug_toolbar_actions + + # ---- Source menu/toolbar construction ---- + source_menu_actions = [ + showblanks_action, + scrollpastend_action, + showindentguides_action, + showcodefolding_action, + show_classfunc_dropdown_action, + show_codestyle_warnings_action, + show_docstring_warnings_action, + underline_errors, + MENU_SEPARATOR, + self.todo_list_action, + self.warning_list_action, + self.previous_warning_action, + self.next_warning_action, + MENU_SEPARATOR, + self.previous_edit_cursor_action, + self.previous_cursor_action, + self.next_cursor_action, + MENU_SEPARATOR, + eol_menu, + trailingspaces_action, + fixindentation_action, + self.formatting_action + ] + self.main.source_menu_actions = ( + source_menu_actions + self.main.source_menu_actions) + + # ---- Dock widget and file dependent actions ---- + self.dock_toolbar_actions = ( + file_toolbar_actions + + [MENU_SEPARATOR] + + run_toolbar_actions + + [MENU_SEPARATOR] + + debug_toolbar_actions + ) + self.pythonfile_dependent_actions = [ + run_action, + configure_action, + set_clear_breakpoint_action, + set_cond_breakpoint_action, + self.debug_action, + self.debug_cell_action, + run_selected_action, + run_cell_action, + run_cell_advance_action, + re_run_last_cell_action, + blockcomment_action, + unblockcomment_action, + ] + self.cythonfile_compatible_actions = [run_action, configure_action] + self.file_dependent_actions = ( + self.pythonfile_dependent_actions + + [ + self.save_action, + save_as_action, + save_copy_as_action, + print_preview_action, + self.print_action, + self.save_all_action, + gotoline_action, + workdir_action, + self.close_action, + self.close_all_action, + self.toggle_comment_action, + self.revert_action, + self.indent_action, + self.unindent_action + ] + ) + self.stack_menu_actions = [gotoline_action, workdir_action] + + return self.file_dependent_actions + + def update_pdb_state(self, state, last_step): + """ + Enable/disable debugging actions and handle pdb state change. + + Some examples depending on the debugging state: + self.debug_action.setEnabled(not state) + self.debug_cell_action.setEnabled(not state) + self.debug_next_action.setEnabled(state) + self.debug_step_action.setEnabled(state) + self.debug_return_action.setEnabled(state) + self.debug_continue_action.setEnabled(state) + self.debug_exit_action.setEnabled(state) + """ + current_editor = self.get_current_editor() + if current_editor: + current_editor.update_debugger_panel_state(state, last_step) + + def register_plugin(self): + """Register plugin in Spyder's main window""" + completions = self.main.get_plugin(Plugins.Completions, error=False) + outlineexplorer = self.main.get_plugin( + Plugins.OutlineExplorer, error=False) + ipyconsole = self.main.get_plugin(Plugins.IPythonConsole, error=False) + + self.main.restore_scrollbar_position.connect( + self.restore_scrollbar_position) + self.main.console.sig_edit_goto_requested.connect(self.load) + self.redirect_stdio.connect(self.main.redirect_internalshell_stdio) + + if completions: + self.main.completions.sig_language_completions_available.connect( + self.register_completion_capabilities) + self.main.completions.sig_open_file.connect(self.load) + self.main.completions.sig_editor_rpc.connect(self._rpc_call) + self.main.completions.sig_stop_completions.connect( + self.stop_completion_services) + + self.sig_file_opened_closed_or_updated.connect( + self.main.completions.file_opened_closed_or_updated) + + if outlineexplorer: + self.set_outlineexplorer(self.main.outlineexplorer) + + if ipyconsole: + ipyconsole.register_spyder_kernel_call_handler( + 'cell_count', self.handle_cell_count) + ipyconsole.register_spyder_kernel_call_handler( + 'current_filename', self.handle_current_filename) + ipyconsole.register_spyder_kernel_call_handler( + 'get_file_code', self.handle_get_file_code) + ipyconsole.register_spyder_kernel_call_handler( + 'run_cell', self.handle_run_cell) + + self.add_dockwidget() + self.update_pdb_state(False, {}) + + # Add modes to switcher + self.switcher_manager = EditorSwitcherManager( + self, + self.main.switcher, + lambda: self.get_current_editor(), + lambda: self.get_current_editorstack(), + section=self.get_plugin_title()) + + def update_source_menu(self, options, **kwargs): + option_names = [opt[-1] if isinstance(opt, tuple) else opt + for opt in options] + named_options = dict(zip(option_names, options)) + for name, action in self.checkable_actions.items(): + if name in named_options: + if name == 'underline_errors': + section = 'editor' + opt = 'underline_errors' + else: + section = 'completions' + opt = named_options[name] + + state = self.get_option(opt, section=section) + + # Avoid triggering the action when this action changes state + # See: spyder-ide/spyder#9915 + action.blockSignals(True) + action.setChecked(state) + action.blockSignals(False) + + def update_font(self): + """Update font from Preferences""" + font = self.get_font() + color_scheme = self.get_color_scheme() + for editorstack in self.editorstacks: + editorstack.set_default_font(font, color_scheme) + completion_size = CONF.get('main', 'completion/size') + for finfo in editorstack.data: + comp_widget = finfo.editor.completion_widget + kite_call_to_action = finfo.editor.kite_call_to_action + comp_widget.setup_appearance(completion_size, font) + kite_call_to_action.setFont(font) + + def set_ancestor(self, ancestor): + """ + Set ancestor of child widgets like the CompletionWidget. + + Needed to properly set position of the widget based on the correct + parent/ancestor. + + See spyder-ide/spyder#11076 + """ + for editorstack in self.editorstacks: + for finfo in editorstack.data: + comp_widget = finfo.editor.completion_widget + kite_call_to_action = finfo.editor.kite_call_to_action + + # This is necessary to catch an error when the plugin is + # undocked and docked back, and (probably) a completion is + # in progress. + # Fixes spyder-ide/spyder#17486 + try: + comp_widget.setParent(ancestor) + kite_call_to_action.setParent(ancestor) + except RuntimeError: + pass + + def _create_checkable_action(self, text, conf_name, method=''): + """Helper function to create a checkable action. + + Args: + text (str): Text to be displayed in the action. + conf_name (str): configuration setting associated with the + action + method (str): name of EditorStack class that will be used + to update the changes in each editorstack. + """ + def toogle(checked): + self.switch_to_plugin() + self._toggle_checkable_action(checked, method, conf_name) + + action = create_action(self, text, toggled=toogle) + action.blockSignals(True) + + if conf_name not in ['pycodestyle', 'pydocstyle']: + action.setChecked(self.get_option(conf_name)) + else: + opt = CONF.get( + 'completions', + ('provider_configuration', 'lsp', 'values', conf_name), + False + ) + action.setChecked(opt) + + action.blockSignals(False) + + return action + + @Slot(bool, str, str) + def _toggle_checkable_action(self, checked, method_name, conf_name): + """ + Handle the toogle of a checkable action. + + Update editorstacks, PyLS and CONF. + + Args: + checked (bool): State of the action. + method_name (str): name of EditorStack class that will be used + to update the changes in each editorstack. + conf_name (str): configuration setting associated with the + action. + """ + if method_name: + if self.editorstacks: + for editorstack in self.editorstacks: + try: + method = getattr(editorstack, method_name) + method(checked) + except AttributeError as e: + logger.error(e, exc_info=True) + self.set_option(conf_name, checked) + else: + if conf_name in ('pycodestyle', 'pydocstyle'): + CONF.set( + 'completions', + ('provider_configuration', 'lsp', 'values', conf_name), + checked) + if self.main.get_plugin(Plugins.Completions, error=False): + completions = self.main.completions + completions.after_configuration_update([]) + + #------ Focus tabwidget + def __get_focused_editorstack(self): + fwidget = QApplication.focusWidget() + if isinstance(fwidget, EditorStack): + return fwidget + else: + for editorstack in self.editorstacks: + if editorstack.isAncestorOf(fwidget): + return editorstack + + def set_last_focused_editorstack(self, editorwindow, editorstack): + self.last_focused_editorstack[editorwindow] = editorstack + # very last editorstack + self.last_focused_editorstack[None] = editorstack + + def get_last_focused_editorstack(self, editorwindow=None): + return self.last_focused_editorstack[editorwindow] + + def remove_last_focused_editorstack(self, editorstack): + for editorwindow, widget in list( + self.last_focused_editorstack.items()): + if widget is editorstack: + self.last_focused_editorstack[editorwindow] = None + + def save_focused_editorstack(self): + editorstack = self.__get_focused_editorstack() + if editorstack is not None: + for win in [self]+self.editorwindows: + if win.isAncestorOf(editorstack): + self.set_last_focused_editorstack(win, editorstack) + + # ------ Handling editorstacks + def register_editorstack(self, editorstack): + self.editorstacks.append(editorstack) + self.register_widget_shortcuts(editorstack) + + if self.isAncestorOf(editorstack): + # editorstack is a child of the Editor plugin + self.set_last_focused_editorstack(self, editorstack) + editorstack.set_closable(len(self.editorstacks) > 1) + if self.outlineexplorer is not None: + editorstack.set_outlineexplorer( + self.outlineexplorer.get_widget()) + editorstack.set_find_widget(self.find_widget) + editorstack.reset_statusbar.connect(self.readwrite_status.hide) + editorstack.reset_statusbar.connect(self.encoding_status.hide) + editorstack.reset_statusbar.connect(self.cursorpos_status.hide) + editorstack.readonly_changed.connect( + self.readwrite_status.update_readonly) + editorstack.encoding_changed.connect( + self.encoding_status.update_encoding) + editorstack.sig_editor_cursor_position_changed.connect( + self.cursorpos_status.update_cursor_position) + editorstack.sig_editor_cursor_position_changed.connect( + self.current_editor_cursor_changed) + editorstack.sig_refresh_eol_chars.connect( + self.eol_status.update_eol) + editorstack.current_file_changed.connect( + self.vcs_status.update_vcs) + editorstack.file_saved.connect( + self.vcs_status.update_vcs_state) + + editorstack.set_io_actions(self.new_action, self.open_action, + self.save_action, self.revert_action) + editorstack.set_tempfile_path(self.TEMPFILE_PATH) + + settings = ( + ('set_todolist_enabled', 'todo_list'), + ('set_blanks_enabled', 'blank_spaces'), + ('set_underline_errors_enabled', 'underline_errors'), + ('set_scrollpastend_enabled', 'scroll_past_end'), + ('set_linenumbers_enabled', 'line_numbers'), + ('set_edgeline_enabled', 'edge_line'), + ('set_indent_guides', 'indent_guides'), + ('set_code_folding_enabled', 'code_folding'), + ('set_focus_to_editor', 'focus_to_editor'), + ('set_run_cell_copy', 'run_cell_copy'), + ('set_close_parentheses_enabled', 'close_parentheses'), + ('set_close_quotes_enabled', 'close_quotes'), + ('set_add_colons_enabled', 'add_colons'), + ('set_auto_unindent_enabled', 'auto_unindent'), + ('set_indent_chars', 'indent_chars'), + ('set_tab_stop_width_spaces', 'tab_stop_width_spaces'), + ('set_wrap_enabled', 'wrap'), + ('set_tabmode_enabled', 'tab_always_indent'), + ('set_stripmode_enabled', 'strip_trailing_spaces_on_modify'), + ('set_intelligent_backspace_enabled', 'intelligent_backspace'), + ('set_automatic_completions_enabled', 'automatic_completions'), + ('set_automatic_completions_after_chars', + 'automatic_completions_after_chars'), + ('set_automatic_completions_after_ms', + 'automatic_completions_after_ms'), + ('set_completions_hint_enabled', 'completions_hint'), + ('set_completions_hint_after_ms', + 'completions_hint_after_ms'), + ('set_highlight_current_line_enabled', 'highlight_current_line'), + ('set_highlight_current_cell_enabled', 'highlight_current_cell'), + ('set_occurrence_highlighting_enabled', 'occurrence_highlighting'), + ('set_occurrence_highlighting_timeout', 'occurrence_highlighting/timeout'), + ('set_checkeolchars_enabled', 'check_eol_chars'), + ('set_tabbar_visible', 'show_tab_bar'), + ('set_classfunc_dropdown_visible', 'show_class_func_dropdown'), + ('set_always_remove_trailing_spaces', 'always_remove_trailing_spaces'), + ('set_remove_trailing_newlines', 'always_remove_trailing_newlines'), + ('set_add_newline', 'add_newline'), + ('set_convert_eol_on_save', 'convert_eol_on_save'), + ('set_convert_eol_on_save_to', 'convert_eol_on_save_to'), + ) + + for method, setting in settings: + getattr(editorstack, method)(self.get_option(setting)) + + editorstack.set_help_enabled(CONF.get('help', 'connect/editor')) + + hover_hints = CONF.get( + 'completions', + ('provider_configuration', 'lsp', 'values', + 'enable_hover_hints'), + True + ) + + format_on_save = CONF.get( + 'completions', + ('provider_configuration', 'lsp', 'values', 'format_on_save'), + False + ) + + edge_line_columns = CONF.get( + 'completions', + ('provider_configuration', 'lsp', 'values', + 'pycodestyle/max_line_length'), + 79 + ) + + editorstack.set_hover_hints_enabled(hover_hints) + editorstack.set_format_on_save(format_on_save) + editorstack.set_edgeline_columns(edge_line_columns) + color_scheme = self.get_color_scheme() + editorstack.set_default_font(self.get_font(), color_scheme) + + editorstack.starting_long_process.connect(self.starting_long_process) + editorstack.ending_long_process.connect(self.ending_long_process) + + # Redirect signals + editorstack.sig_option_changed.connect(self.sig_option_changed) + editorstack.redirect_stdio.connect( + lambda state: self.redirect_stdio.emit(state)) + editorstack.exec_in_extconsole.connect( + lambda text, option: + self.exec_in_extconsole.emit(text, option)) + editorstack.run_cell_in_ipyclient.connect(self.run_cell_in_ipyclient) + editorstack.debug_cell_in_ipyclient.connect( + self.debug_cell_in_ipyclient) + editorstack.update_plugin_title.connect( + lambda: self.sig_update_plugin_title.emit()) + editorstack.editor_focus_changed.connect(self.save_focused_editorstack) + editorstack.editor_focus_changed.connect(self.main.plugin_focus_changed) + editorstack.editor_focus_changed.connect(self.sig_editor_focus_changed) + editorstack.zoom_in.connect(lambda: self.zoom(1)) + editorstack.zoom_out.connect(lambda: self.zoom(-1)) + editorstack.zoom_reset.connect(lambda: self.zoom(0)) + editorstack.sig_open_file.connect(self.report_open_file) + editorstack.sig_new_file.connect(lambda s: self.new(text=s)) + editorstack.sig_new_file[()].connect(self.new) + editorstack.sig_close_file.connect(self.close_file_in_all_editorstacks) + editorstack.sig_close_file.connect(self.remove_file_cursor_history) + editorstack.file_saved.connect(self.file_saved_in_editorstack) + editorstack.file_renamed_in_data.connect( + self.file_renamed_in_data_in_editorstack) + editorstack.opened_files_list_changed.connect( + self.opened_files_list_changed) + editorstack.active_languages_stats.connect( + self.update_active_languages) + editorstack.sig_go_to_definition.connect( + lambda fname, line, col: self.load( + fname, line, start_column=col)) + editorstack.sig_perform_completion_request.connect( + self.send_completion_request) + editorstack.todo_results_changed.connect(self.todo_results_changed) + editorstack.update_code_analysis_actions.connect( + self.update_code_analysis_actions) + editorstack.update_code_analysis_actions.connect( + self.update_todo_actions) + editorstack.refresh_file_dependent_actions.connect( + self.refresh_file_dependent_actions) + editorstack.refresh_save_all_action.connect(self.refresh_save_all_action) + editorstack.sig_refresh_eol_chars.connect(self.refresh_eol_chars) + editorstack.sig_refresh_formatting.connect(self.refresh_formatting) + editorstack.sig_breakpoints_saved.connect(self.breakpoints_saved) + editorstack.text_changed_at.connect(self.text_changed_at) + editorstack.current_file_changed.connect(self.current_file_changed) + editorstack.plugin_load.connect(self.load) + editorstack.plugin_load[()].connect(self.load) + editorstack.edit_goto.connect(self.load) + editorstack.sig_save_as.connect(self.save_as) + editorstack.sig_prev_edit_pos.connect(self.go_to_last_edit_location) + editorstack.sig_prev_cursor.connect(self.go_to_previous_cursor_position) + editorstack.sig_next_cursor.connect(self.go_to_next_cursor_position) + editorstack.sig_prev_warning.connect(self.go_to_previous_warning) + editorstack.sig_next_warning.connect(self.go_to_next_warning) + editorstack.sig_save_bookmark.connect(self.save_bookmark) + editorstack.sig_load_bookmark.connect(self.load_bookmark) + editorstack.sig_save_bookmarks.connect(self.save_bookmarks) + editorstack.sig_help_requested.connect(self.sig_help_requested) + + # Register editorstack's autosave component with plugin's autosave + # component + self.autosave.register_autosave_for_stack(editorstack.autosave) + + def unregister_editorstack(self, editorstack): + """Removing editorstack only if it's not the last remaining""" + self.remove_last_focused_editorstack(editorstack) + if len(self.editorstacks) > 1: + index = self.editorstacks.index(editorstack) + self.editorstacks.pop(index) + return True + else: + # editorstack was not removed! + return False + + def clone_editorstack(self, editorstack): + editorstack.clone_from(self.editorstacks[0]) + for finfo in editorstack.data: + self.register_widget_shortcuts(finfo.editor) + + @Slot(str, str) + def close_file_in_all_editorstacks(self, editorstack_id_str, filename): + for editorstack in self.editorstacks: + if str(id(editorstack)) != editorstack_id_str: + editorstack.blockSignals(True) + index = editorstack.get_index_from_filename(filename) + editorstack.close_file(index, force=True) + editorstack.blockSignals(False) + + @Slot(str, str, str) + def file_saved_in_editorstack(self, editorstack_id_str, + original_filename, filename): + """A file was saved in editorstack, this notifies others""" + for editorstack in self.editorstacks: + if str(id(editorstack)) != editorstack_id_str: + editorstack.file_saved_in_other_editorstack(original_filename, + filename) + + @Slot(str, str, str) + def file_renamed_in_data_in_editorstack(self, editorstack_id_str, + original_filename, filename): + """A file was renamed in data in editorstack, this notifies others""" + for editorstack in self.editorstacks: + if str(id(editorstack)) != editorstack_id_str: + editorstack.rename_in_data(original_filename, filename) + + #------ Handling editor windows + def setup_other_windows(self): + """Setup toolbars and menus for 'New window' instances""" + # TODO: All the actions here should be taken from + # the MainMenus plugin + file_menu_actions = self.main.mainmenu.get_application_menu( + ApplicationMenus.File).get_actions() + tools_menu_actions = self.main.mainmenu.get_application_menu( + ApplicationMenus.Tools).get_actions() + help_menu_actions = self.main.mainmenu.get_application_menu( + ApplicationMenus.Help).get_actions() + + self.toolbar_list = ((_("File toolbar"), "file_toolbar", + self.main.file_toolbar_actions), + (_("Run toolbar"), "run_toolbar", + self.main.run_toolbar_actions), + (_("Debug toolbar"), "debug_toolbar", + self.main.debug_toolbar_actions)) + + self.menu_list = ((_("&File"), file_menu_actions), + (_("&Edit"), self.main.edit_menu_actions), + (_("&Search"), self.main.search_menu_actions), + (_("Sour&ce"), self.main.source_menu_actions), + (_("&Run"), self.main.run_menu_actions), + (_("&Tools"), tools_menu_actions), + (_("&View"), []), + (_("&Help"), help_menu_actions)) + # Create pending new windows: + for layout_settings in self.editorwindows_to_be_created: + win = self.create_new_window() + win.set_layout_settings(layout_settings) + + def switch_to_plugin(self): + """ + Reimplemented method to deactivate shortcut when + opening a new window. + """ + if not self.editorwindows: + super(Editor, self).switch_to_plugin() + + def create_new_window(self): + window = EditorMainWindow( + self, self.stack_menu_actions, self.toolbar_list, self.menu_list) + window.add_toolbars_to_menu("&View", window.get_toolbars()) + window.load_toolbars() + window.resize(self.size()) + window.show() + window.editorwidget.editorsplitter.editorstack.new_window = True + self.register_editorwindow(window) + window.destroyed.connect(lambda: self.unregister_editorwindow(window)) + return window + + def register_editorwindow(self, window): + self.editorwindows.append(window) + + def unregister_editorwindow(self, window): + self.editorwindows.pop(self.editorwindows.index(window)) + + + #------ Accessors + def get_filenames(self): + return [finfo.filename for finfo in self.editorstacks[0].data] + + def get_filename_index(self, filename): + return self.editorstacks[0].has_filename(filename) + + def get_current_editorstack(self, editorwindow=None): + if self.editorstacks is not None: + if len(self.editorstacks) == 1: + editorstack = self.editorstacks[0] + else: + editorstack = self.__get_focused_editorstack() + if editorstack is None or editorwindow is not None: + editorstack = self.get_last_focused_editorstack( + editorwindow) + if editorstack is None: + editorstack = self.editorstacks[0] + return editorstack + + def get_current_editor(self): + editorstack = self.get_current_editorstack() + if editorstack is not None: + return editorstack.get_current_editor() + + def get_current_finfo(self): + editorstack = self.get_current_editorstack() + if editorstack is not None: + return editorstack.get_current_finfo() + + def get_current_filename(self): + editorstack = self.get_current_editorstack() + if editorstack is not None: + return editorstack.get_current_filename() + + def get_current_language(self): + editorstack = self.get_current_editorstack() + if editorstack is not None: + return editorstack.get_current_language() + + def is_file_opened(self, filename=None): + return self.editorstacks[0].is_file_opened(filename) + + def set_current_filename(self, filename, editorwindow=None, focus=True): + """Set focus to *filename* if this file has been opened. + + Return the editor instance associated to *filename*. + """ + editorstack = self.get_current_editorstack(editorwindow) + return editorstack.set_current_filename(filename, focus) + + def set_path(self): + for finfo in self.editorstacks[0].data: + finfo.path = self.main.get_spyder_pythonpath() + + #------ Refresh methods + def refresh_file_dependent_actions(self): + """Enable/disable file dependent actions + (only if dockwidget is visible)""" + if self.dockwidget and self.dockwidget.isVisible(): + enable = self.get_current_editor() is not None + for action in self.file_dependent_actions: + action.setEnabled(enable) + + def refresh_save_all_action(self): + """Enable 'Save All' if there are files to be saved""" + editorstack = self.get_current_editorstack() + if editorstack: + state = any(finfo.editor.document().isModified() or finfo.newly_created + for finfo in editorstack.data) + self.save_all_action.setEnabled(state) + + def update_warning_menu(self): + """Update warning list menu""" + editor = self.get_current_editor() + check_results = editor.get_current_warnings() + self.warning_menu.clear() + filename = self.get_current_filename() + for message, line_number in check_results: + error = 'syntax' in message + text = message[:1].upper() + message[1:] + icon = ima.icon('error') if error else ima.icon('warning') + slot = lambda _checked, _l=line_number: self.load(filename, goto=_l) + action = create_action(self, text=text, icon=icon) + action.triggered[bool].connect(slot) + self.warning_menu.addAction(action) + + def update_todo_menu(self): + """Update todo list menu""" + editorstack = self.get_current_editorstack() + results = editorstack.get_todo_results() + self.todo_menu.clear() + filename = self.get_current_filename() + for text, line0 in results: + icon = ima.icon('todo') + slot = lambda _checked, _l=line0: self.load(filename, goto=_l) + action = create_action(self, text=text, icon=icon) + action.triggered[bool].connect(slot) + self.todo_menu.addAction(action) + self.update_todo_actions() + + def todo_results_changed(self): + """ + Synchronize todo results between editorstacks + Refresh todo list navigation buttons + """ + editorstack = self.get_current_editorstack() + results = editorstack.get_todo_results() + index = editorstack.get_stack_index() + if index != -1: + filename = editorstack.data[index].filename + for other_editorstack in self.editorstacks: + if other_editorstack is not editorstack: + other_editorstack.set_todo_results(filename, results) + self.update_todo_actions() + + def refresh_eol_chars(self, os_name): + os_name = to_text_string(os_name) + self.__set_eol_chars = False + if os_name == 'nt': + self.win_eol_action.setChecked(True) + elif os_name == 'posix': + self.linux_eol_action.setChecked(True) + else: + self.mac_eol_action.setChecked(True) + self.__set_eol_chars = True + + def refresh_formatting(self, status): + self.formatting_action.setEnabled(status) + + def refresh_formatter_name(self): + formatter = CONF.get( + 'completions', + ('provider_configuration', 'lsp', 'values', 'formatting'), + '') + self.formatting_action.setText( + _('Format file or selection with {0}').format( + formatter.capitalize())) + + #------ Slots + def opened_files_list_changed(self): + """ + Opened files list has changed: + --> open/close file action + --> modification ('*' added to title) + --> current edited file has changed + """ + # Refresh Python file dependent actions: + editor = self.get_current_editor() + if editor: + python_enable = editor.is_python_or_ipython() + cython_enable = python_enable or ( + programs.is_module_installed('Cython') and editor.is_cython()) + for action in self.pythonfile_dependent_actions: + if action in self.cythonfile_compatible_actions: + enable = cython_enable + else: + enable = python_enable + action.setEnabled(enable) + self.sig_file_opened_closed_or_updated.emit( + self.get_current_filename(), self.get_current_language()) + + def update_code_analysis_actions(self): + """Update actions in the warnings menu.""" + editor = self.get_current_editor() + + # To fix an error at startup + if editor is None: + return + + # Update actions state if there are errors present + for action in (self.warning_list_action, self.previous_warning_action, + self.next_warning_action): + action.setEnabled(editor.errors_present()) + + def update_todo_actions(self): + editorstack = self.get_current_editorstack() + results = editorstack.get_todo_results() + state = (self.get_option('todo_list') and + results is not None and len(results)) + if state is not None: + self.todo_list_action.setEnabled(state) + + @Slot(set) + def update_active_languages(self, languages): + if self.main.get_plugin(Plugins.Completions, error=False): + self.main.completions.update_client_status(languages) + + # ------ Bookmarks + def save_bookmarks(self, filename, bookmarks): + """Receive bookmark changes and save them.""" + filename = to_text_string(filename) + bookmarks = to_text_string(bookmarks) + filename = osp.normpath(osp.abspath(filename)) + bookmarks = eval(bookmarks) + save_bookmarks(filename, bookmarks) + + #------ File I/O + def __load_temp_file(self): + """Load temporary file from a text file in user home directory""" + if not osp.isfile(self.TEMPFILE_PATH): + # Creating temporary file + default = ['# -*- coding: utf-8 -*-', + '"""', _("Spyder Editor"), '', + _("This is a temporary script file."), + '"""', '', ''] + text = os.linesep.join([encoding.to_unicode(qstr) + for qstr in default]) + try: + encoding.write(to_text_string(text), self.TEMPFILE_PATH, + 'utf-8') + except EnvironmentError: + self.new() + return + + self.load(self.TEMPFILE_PATH) + + @Slot() + def __set_workdir(self): + """Set current script directory as working directory""" + fname = self.get_current_filename() + if fname is not None: + directory = osp.dirname(osp.abspath(fname)) + self.sig_dir_opened.emit(directory) + + def __add_recent_file(self, fname): + """Add to recent file list""" + if fname is None: + return + if fname in self.recent_files: + self.recent_files.remove(fname) + self.recent_files.insert(0, fname) + if len(self.recent_files) > self.get_option('max_recent_files'): + self.recent_files.pop(-1) + + def _clone_file_everywhere(self, finfo): + """Clone file (*src_editor* widget) in all editorstacks + Cloning from the first editorstack in which every single new editor + is created (when loading or creating a new file)""" + for editorstack in self.editorstacks[1:]: + editor = editorstack.clone_editor_from(finfo, set_current=False) + self.register_widget_shortcuts(editor) + + + @Slot() + @Slot(str) + def new(self, fname=None, editorstack=None, text=None): + """ + Create a new file - Untitled + + fname=None --> fname will be 'untitledXX.py' but do not create file + fname= --> create file + """ + # If no text is provided, create default content + empty = False + try: + if text is None: + default_content = True + text, enc = encoding.read(self.TEMPLATE_PATH) + enc_match = re.search(r'-*- coding: ?([a-z0-9A-Z\-]*) -*-', + text) + if enc_match: + enc = enc_match.group(1) + # Initialize template variables + # Windows + username = encoding.to_unicode_from_fs( + os.environ.get('USERNAME', '')) + # Linux, Mac OS X + if not username: + username = encoding.to_unicode_from_fs( + os.environ.get('USER', '-')) + VARS = { + 'date': time.ctime(), + 'username': username, + } + try: + text = text % VARS + except Exception: + pass + else: + default_content = False + enc = encoding.read(self.TEMPLATE_PATH)[1] + except (IOError, OSError): + text = '' + enc = 'utf-8' + default_content = True + + create_fname = lambda n: to_text_string(_("untitled")) + ("%d.py" % n) + # Creating editor widget + if editorstack is None: + current_es = self.get_current_editorstack() + else: + current_es = editorstack + created_from_here = fname is None + if created_from_here: + if self.untitled_num == 0: + for finfo in current_es.data: + current_filename = finfo.editor.filename + if _("untitled") in current_filename: + # Start the counter of the untitled_num with respect + # to this number if there's other untitled file in + # spyder. Please see spyder-ide/spyder#7831 + fname_data = osp.splitext(current_filename) + try: + act_num = int( + fname_data[0].split(_("untitled"))[-1]) + self.untitled_num = act_num + 1 + except ValueError: + # Catch the error in case the user has something + # different from a number after the untitled + # part. + # Please see spyder-ide/spyder#12892 + self.untitled_num = 0 + while True: + fname = create_fname(self.untitled_num) + self.untitled_num += 1 + if not osp.isfile(fname): + break + basedir = getcwd_or_home() + + projects = self.main.get_plugin(Plugins.Projects, error=False) + if projects and projects.get_active_project() is not None: + basedir = projects.get_active_project_path() + else: + c_fname = self.get_current_filename() + if c_fname is not None and c_fname != self.TEMPFILE_PATH: + basedir = osp.dirname(c_fname) + fname = osp.abspath(osp.join(basedir, fname)) + else: + # QString when triggered by a Qt signal + fname = osp.abspath(to_text_string(fname)) + index = current_es.has_filename(fname) + if index is not None and not current_es.close_file(index): + return + + # Creating the editor widget in the first editorstack (the one that + # can't be destroyed), then cloning this editor widget in all other + # editorstacks: + # Setting empty to True by default to avoid the additional space + # created at the end of the templates. + # See: spyder-ide/spyder#12596 + finfo = self.editorstacks[0].new(fname, enc, text, default_content, + empty=True) + finfo.path = self.main.get_spyder_pythonpath() + self._clone_file_everywhere(finfo) + current_editor = current_es.set_current_filename(finfo.filename) + self.register_widget_shortcuts(current_editor) + if not created_from_here: + self.save(force=True) + + def edit_template(self): + """Edit new file template""" + self.load(self.TEMPLATE_PATH) + + def update_recent_file_menu(self): + """Update recent file menu""" + recent_files = [] + for fname in self.recent_files: + if osp.isfile(fname): + recent_files.append(fname) + self.recent_file_menu.clear() + if recent_files: + for fname in recent_files: + action = create_action( + self, fname, + icon=ima.get_icon_by_extension_or_type( + fname, scale_factor=1.0)) + action.triggered[bool].connect(self.load) + action.setData(to_qvariant(fname)) + self.recent_file_menu.addAction(action) + self.clear_recent_action.setEnabled(len(recent_files) > 0) + add_actions(self.recent_file_menu, (None, self.max_recent_action, + self.clear_recent_action)) + + @Slot() + def clear_recent_files(self): + """Clear recent files list""" + self.recent_files = [] + + @Slot() + def change_max_recent_files(self): + "Change max recent files entries""" + editorstack = self.get_current_editorstack() + mrf, valid = QInputDialog.getInt(editorstack, _('Editor'), + _('Maximum number of recent files'), + self.get_option('max_recent_files'), 1, 35) + if valid: + self.set_option('max_recent_files', mrf) + + @Slot() + @Slot(str) + @Slot(str, int, str) + @Slot(str, int, str, object) + def load(self, filenames=None, goto=None, word='', + editorwindow=None, processevents=True, start_column=None, + end_column=None, set_focus=True, add_where='end'): + """ + Load a text file + editorwindow: load in this editorwindow (useful when clicking on + outline explorer with multiple editor windows) + processevents: determines if processEvents() should be called at the + end of this method (set to False to prevent keyboard events from + creeping through to the editor during debugging) + If goto is not none it represent a line to go to. start_column is + the start position in this line and end_column the length + (So that the end position is start_column + end_column) + Alternatively, the first match of word is used as a position. + """ + cursor_history_state = self.__ignore_cursor_history + self.__ignore_cursor_history = True + # Switch to editor before trying to load a file + try: + self.switch_to_plugin() + except AttributeError: + pass + + editor0 = self.get_current_editor() + if editor0 is not None: + filename0 = self.get_current_filename() + else: + filename0 = None + if not filenames: + # Recent files action + action = self.sender() + if isinstance(action, QAction): + filenames = from_qvariant(action.data(), to_text_string) + if not filenames: + basedir = getcwd_or_home() + if self.edit_filetypes is None: + self.edit_filetypes = get_edit_filetypes() + if self.edit_filters is None: + self.edit_filters = get_edit_filters() + + c_fname = self.get_current_filename() + if c_fname is not None and c_fname != self.TEMPFILE_PATH: + basedir = osp.dirname(c_fname) + + self.redirect_stdio.emit(False) + parent_widget = self.get_current_editorstack() + if filename0 is not None: + selectedfilter = get_filter(self.edit_filetypes, + osp.splitext(filename0)[1]) + else: + selectedfilter = '' + + if not running_under_pytest(): + # See: spyder-ide/spyder#3291 + if sys.platform == 'darwin': + dialog = QFileDialog( + parent=parent_widget, + caption=_("Open file"), + directory=basedir, + ) + dialog.setNameFilters(self.edit_filters.split(';;')) + dialog.setOption(QFileDialog.HideNameFilterDetails, True) + dialog.setFilter(QDir.AllDirs | QDir.Files | QDir.Drives + | QDir.Hidden) + dialog.setFileMode(QFileDialog.ExistingFiles) + + if dialog.exec_(): + filenames = dialog.selectedFiles() + else: + filenames, _sf = getopenfilenames( + parent_widget, + _("Open file"), + basedir, + self.edit_filters, + selectedfilter=selectedfilter, + options=QFileDialog.HideNameFilterDetails, + ) + else: + # Use a Qt (i.e. scriptable) dialog for pytest + dialog = QFileDialog(parent_widget, _("Open file"), + options=QFileDialog.DontUseNativeDialog) + if dialog.exec_(): + filenames = dialog.selectedFiles() + + self.redirect_stdio.emit(True) + + if filenames: + filenames = [osp.normpath(fname) for fname in filenames] + else: + self.__ignore_cursor_history = cursor_history_state + return + + focus_widget = QApplication.focusWidget() + if self.editorwindows and not self.dockwidget.isVisible(): + # We override the editorwindow variable to force a focus on + # the editor window instead of the hidden editor dockwidget. + # See spyder-ide/spyder#5742. + if editorwindow not in self.editorwindows: + editorwindow = self.editorwindows[0] + editorwindow.setFocus() + editorwindow.raise_() + elif (self.dockwidget and not self._ismaximized + and not self.dockwidget.isAncestorOf(focus_widget) + and not isinstance(focus_widget, CodeEditor)): + self.switch_to_plugin() + + def _convert(fname): + fname = osp.abspath(encoding.to_unicode_from_fs(fname)) + if os.name == 'nt' and len(fname) >= 2 and fname[1] == ':': + fname = fname[0].upper()+fname[1:] + return fname + + if hasattr(filenames, 'replaceInStrings'): + # This is a QStringList instance (PyQt API #1), converting to list: + filenames = list(filenames) + if not isinstance(filenames, list): + filenames = [_convert(filenames)] + else: + filenames = [_convert(fname) for fname in list(filenames)] + if isinstance(goto, int): + goto = [goto] + elif goto is not None and len(goto) != len(filenames): + goto = None + + for index, filename in enumerate(filenames): + # -- Do not open an already opened file + focus = set_focus and index == 0 + current_editor = self.set_current_filename(filename, + editorwindow, + focus=focus) + if current_editor is None: + # -- Not a valid filename: + if not osp.isfile(filename): + continue + # -- + current_es = self.get_current_editorstack(editorwindow) + # Creating the editor widget in the first editorstack + # (the one that can't be destroyed), then cloning this + # editor widget in all other editorstacks: + finfo = self.editorstacks[0].load( + filename, set_current=False, add_where=add_where, + processevents=processevents) + finfo.path = self.main.get_spyder_pythonpath() + self._clone_file_everywhere(finfo) + current_editor = current_es.set_current_filename(filename, + focus=focus) + current_editor.debugger.load_breakpoints() + current_editor.set_bookmarks(load_bookmarks(filename)) + self.register_widget_shortcuts(current_editor) + current_es.analyze_script() + self.__add_recent_file(filename) + if goto is not None: # 'word' is assumed to be None as well + current_editor.go_to_line(goto[index], word=word, + start_column=start_column, + end_column=end_column) + current_editor.clearFocus() + current_editor.setFocus() + current_editor.window().raise_() + if processevents: + QApplication.processEvents() + else: + # processevents is false only when calling from debugging + current_editor.sig_debug_stop.emit(goto[index]) + + ipyconsole = self.main.get_plugin( + Plugins.IPythonConsole, error=False) + if ipyconsole: + current_sw = ipyconsole.get_current_shellwidget() + current_sw.sig_prompt_ready.connect( + current_editor.sig_debug_stop[()]) + current_pdb_state = ipyconsole.get_pdb_state() + pdb_last_step = ipyconsole.get_pdb_last_step() + self.update_pdb_state(current_pdb_state, pdb_last_step) + + self.__ignore_cursor_history = cursor_history_state + self.add_cursor_to_history() + + def _create_print_editor(self): + """Create a SimpleCodeEditor instance to print file contents.""" + editor = SimpleCodeEditor(self) + editor.setup_editor( + color_scheme="scintilla", highlight_current_line=False + ) + return editor + + @Slot() + def print_file(self): + """Print current file.""" + editor = self.get_current_editor() + filename = self.get_current_filename() + + # Set print editor + self._print_editor.set_text(editor.toPlainText()) + self._print_editor.set_language(editor.language) + self._print_editor.set_font(self.get_font()) + + # Create printer + printer = Printer(mode=QPrinter.HighResolution, + header_font=self.get_font()) + print_dialog = QPrintDialog(printer, self._print_editor) + + # Adjust print options when user has selected text + if editor.has_selected_text(): + print_dialog.setOption(QAbstractPrintDialog.PrintSelection, True) + + # Copy selection from current editor to print editor + cursor_1 = editor.textCursor() + start, end = cursor_1.selectionStart(), cursor_1.selectionEnd() + + cursor_2 = self._print_editor.textCursor() + cursor_2.setPosition(start) + cursor_2.setPosition(end, QTextCursor.KeepAnchor) + self._print_editor.setTextCursor(cursor_2) + + # Print + self.redirect_stdio.emit(False) + answer = print_dialog.exec_() + self.redirect_stdio.emit(True) + + if answer == QDialog.Accepted: + self.starting_long_process(_("Printing...")) + printer.setDocName(filename) + self._print_editor.print_(printer) + self.ending_long_process() + + # Clear selection + self._print_editor.textCursor().removeSelectedText() + + @Slot() + def print_preview(self): + """Print preview for current file.""" + editor = self.get_current_editor() + + # Set print editor + self._print_editor.set_text(editor.toPlainText()) + self._print_editor.set_language(editor.language) + self._print_editor.set_font(self.get_font()) + + # Create printer + printer = Printer(mode=QPrinter.HighResolution, + header_font=self.get_font()) + + # Create preview + preview = QPrintPreviewDialog(printer, self) + preview.setWindowFlags(Qt.Window) + preview.paintRequested.connect( + lambda printer: self._print_editor.print_(printer) + ) + + # Show preview + self.redirect_stdio.emit(False) + preview.exec_() + self.redirect_stdio.emit(True) + + def can_close_file(self, filename=None): + """ + Check if a file can be closed taking into account debugging state. + """ + if not CONF.get('ipython_console', 'pdb_prevent_closing'): + return True + ipyconsole = self.main.get_plugin(Plugins.IPythonConsole, error=False) + + debugging = False + last_pdb_step = {} + if ipyconsole: + debugging = ipyconsole.get_pdb_state() + last_pdb_step = ipyconsole.get_pdb_last_step() + + can_close = True + if debugging and 'fname' in last_pdb_step and filename: + if osp.normcase(last_pdb_step['fname']) == osp.normcase(filename): + can_close = False + self.sig_file_debug_message_requested.emit() + elif debugging: + can_close = False + self.sig_file_debug_message_requested.emit() + return can_close + + @Slot() + def close_file(self): + """Close current file""" + filename = self.get_current_filename() + if self.can_close_file(filename=filename): + editorstack = self.get_current_editorstack() + editorstack.close_file() + + @Slot() + def close_all_files(self): + """Close all opened scripts""" + self.editorstacks[0].close_all_files() + + @Slot() + def save(self, index=None, force=False): + """Save file""" + editorstack = self.get_current_editorstack() + return editorstack.save(index=index, force=force) + + @Slot() + def save_as(self): + """Save *as* the currently edited file""" + editorstack = self.get_current_editorstack() + if editorstack.save_as(): + fname = editorstack.get_current_filename() + self.__add_recent_file(fname) + + @Slot() + def save_copy_as(self): + """Save *copy as* the currently edited file""" + editorstack = self.get_current_editorstack() + editorstack.save_copy_as() + + @Slot() + def save_all(self, save_new_files=True): + """Save all opened files""" + self.get_current_editorstack().save_all(save_new_files=save_new_files) + + @Slot() + def revert(self): + """Revert the currently edited file from disk""" + editorstack = self.get_current_editorstack() + editorstack.revert() + + @Slot() + def find(self): + """Find slot""" + editorstack = self.get_current_editorstack() + editorstack.find_widget.show() + editorstack.find_widget.search_text.setFocus() + + @Slot() + def find_next(self): + """Fnd next slot""" + editorstack = self.get_current_editorstack() + editorstack.find_widget.find_next() + + @Slot() + def find_previous(self): + """Find previous slot""" + editorstack = self.get_current_editorstack() + editorstack.find_widget.find_previous() + + @Slot() + def replace(self): + """Replace slot""" + editorstack = self.get_current_editorstack() + editorstack.find_widget.show_replace() + + def open_last_closed(self): + """ Reopens the last closed tab.""" + editorstack = self.get_current_editorstack() + last_closed_files = editorstack.get_last_closed_files() + if (len(last_closed_files) > 0): + file_to_open = last_closed_files[0] + last_closed_files.remove(file_to_open) + editorstack.set_last_closed_files(last_closed_files) + self.load(file_to_open) + + #------ Explorer widget + def close_file_from_name(self, filename): + """Close file from its name""" + filename = osp.abspath(to_text_string(filename)) + index = self.editorstacks[0].has_filename(filename) + if index is not None: + self.editorstacks[0].close_file(index) + + def removed(self, filename): + """File was removed in file explorer widget or in project explorer""" + self.close_file_from_name(filename) + + def removed_tree(self, dirname): + """Directory was removed in project explorer widget""" + dirname = osp.abspath(to_text_string(dirname)) + for fname in self.get_filenames(): + if osp.abspath(fname).startswith(dirname): + self.close_file_from_name(fname) + + def renamed(self, source, dest): + """ + Propagate file rename to editor stacks and autosave component. + + This function is called when a file is renamed in the file explorer + widget or the project explorer. The file may not be opened in the + editor. + """ + filename = osp.abspath(to_text_string(source)) + index = self.editorstacks[0].has_filename(filename) + if index is not None: + for editorstack in self.editorstacks: + editorstack.rename_in_data(filename, + new_filename=to_text_string(dest)) + self.editorstacks[0].autosave.file_renamed( + filename, to_text_string(dest)) + + def renamed_tree(self, source, dest): + """Directory was renamed in file explorer or in project explorer.""" + dirname = osp.abspath(to_text_string(source)) + tofile = to_text_string(dest) + for fname in self.get_filenames(): + if osp.abspath(fname).startswith(dirname): + new_filename = fname.replace(dirname, tofile) + self.renamed(source=fname, dest=new_filename) + + #------ Source code + @Slot() + def indent(self): + """Indent current line or selection""" + editor = self.get_current_editor() + if editor is not None: + editor.indent() + + @Slot() + def unindent(self): + """Unindent current line or selection""" + editor = self.get_current_editor() + if editor is not None: + editor.unindent() + + @Slot() + def text_uppercase(self): + """Change current line or selection to uppercase.""" + editor = self.get_current_editor() + if editor is not None: + editor.transform_to_uppercase() + + @Slot() + def text_lowercase(self): + """Change current line or selection to lowercase.""" + editor = self.get_current_editor() + if editor is not None: + editor.transform_to_lowercase() + + @Slot() + def toggle_comment(self): + """Comment current line or selection""" + editor = self.get_current_editor() + if editor is not None: + editor.toggle_comment() + + @Slot() + def blockcomment(self): + """Block comment current line or selection""" + editor = self.get_current_editor() + if editor is not None: + editor.blockcomment() + + @Slot() + def unblockcomment(self): + """Un-block comment current line or selection""" + editor = self.get_current_editor() + if editor is not None: + editor.unblockcomment() + @Slot() + def go_to_next_todo(self): + self.switch_to_plugin() + editor = self.get_current_editor() + editor.go_to_next_todo() + filename = self.get_current_filename() + cursor = editor.textCursor() + self.add_cursor_to_history(filename, cursor) + + @Slot() + def go_to_next_warning(self): + self.switch_to_plugin() + editor = self.get_current_editor() + editor.go_to_next_warning() + filename = self.get_current_filename() + cursor = editor.textCursor() + self.add_cursor_to_history(filename, cursor) + + @Slot() + def go_to_previous_warning(self): + self.switch_to_plugin() + editor = self.get_current_editor() + editor.go_to_previous_warning() + filename = self.get_current_filename() + cursor = editor.textCursor() + self.add_cursor_to_history(filename, cursor) + + def toggle_eol_chars(self, os_name, checked): + if checked: + editor = self.get_current_editor() + if self.__set_eol_chars: + self.switch_to_plugin() + editor.set_eol_chars( + eol_chars=sourcecode.get_eol_chars_from_os_name(os_name) + ) + + @Slot() + def remove_trailing_spaces(self): + self.switch_to_plugin() + editorstack = self.get_current_editorstack() + editorstack.remove_trailing_spaces() + + @Slot() + def format_document_or_selection(self): + self.switch_to_plugin() + editorstack = self.get_current_editorstack() + editorstack.format_document_or_selection() + + @Slot() + def fix_indentation(self): + self.switch_to_plugin() + editorstack = self.get_current_editorstack() + editorstack.fix_indentation() + + #------ Cursor position history management + def update_cursorpos_actions(self): + self.previous_edit_cursor_action.setEnabled( + self.last_edit_cursor_pos is not None) + self.previous_cursor_action.setEnabled( + len(self.cursor_undo_history) > 0) + self.next_cursor_action.setEnabled( + len(self.cursor_redo_history) > 0) + + def add_cursor_to_history(self, filename=None, cursor=None): + if self.__ignore_cursor_history: + return + if filename is None: + filename = self.get_current_filename() + if cursor is None: + editor = self._get_editor(filename) + if editor is None: + return + cursor = editor.textCursor() + + replace_last_entry = False + if len(self.cursor_undo_history) > 0: + fname, hist_cursor = self.cursor_undo_history[-1] + if fname == filename: + if cursor.blockNumber() == hist_cursor.blockNumber(): + # Only one cursor per line + replace_last_entry = True + + if replace_last_entry: + self.cursor_undo_history.pop() + else: + # Drop redo stack as we moved + self.cursor_redo_history = [] + + self.cursor_undo_history.append((filename, cursor)) + self.update_cursorpos_actions() + + def text_changed_at(self, filename, position): + self.last_edit_cursor_pos = (to_text_string(filename), position) + + def current_file_changed(self, filename, position, line, column): + cursor = self.get_current_editor().textCursor() + self.add_cursor_to_history(to_text_string(filename), cursor) + + # Hide any open tooltips + current_stack = self.get_current_editorstack() + if current_stack is not None: + current_stack.hide_tooltip() + + # Update debugging state + ipyconsole = self.main.get_plugin(Plugins.IPythonConsole, error=False) + if ipyconsole is not None: + pdb_state = ipyconsole.get_pdb_state() + pdb_last_step = ipyconsole.get_pdb_last_step() + self.update_pdb_state(pdb_state, pdb_last_step) + + def current_editor_cursor_changed(self, line, column): + """Handles the change of the cursor inside the current editor.""" + code_editor = self.get_current_editor() + filename = code_editor.filename + cursor = code_editor.textCursor() + self.add_cursor_to_history( + to_text_string(filename), cursor) + + def remove_file_cursor_history(self, id, filename): + """Remove the cursor history of a file if the file is closed.""" + new_history = [] + for i, (cur_filename, cursor) in enumerate( + self.cursor_undo_history): + if cur_filename != filename: + new_history.append((cur_filename, cursor)) + self.cursor_undo_history = new_history + + new_redo_history = [] + for i, (cur_filename, cursor) in enumerate( + self.cursor_redo_history): + if cur_filename != filename: + new_redo_history.append((cur_filename, cursor)) + self.cursor_redo_history = new_redo_history + + @Slot() + def go_to_last_edit_location(self): + if self.last_edit_cursor_pos is not None: + filename, position = self.last_edit_cursor_pos + if not osp.isfile(filename): + self.last_edit_cursor_pos = None + return + else: + self.load(filename) + editor = self.get_current_editor() + if position < editor.document().characterCount(): + editor.set_cursor_position(position) + + def _pop_next_cursor_diff(self, history, current_filename, current_cursor): + """Get the next cursor from history that is different from current.""" + while history: + filename, cursor = history.pop() + if (filename != current_filename or + cursor.position() != current_cursor.position()): + return filename, cursor + return None, None + + def _history_steps(self, number_steps, + backwards_history, forwards_history, + current_filename, current_cursor): + """ + Move number_steps in the forwards_history, filling backwards_history. + """ + for i in range(number_steps): + if len(forwards_history) > 0: + # Put the current cursor in history + backwards_history.append( + (current_filename, current_cursor)) + # Extract the next different cursor + current_filename, current_cursor = ( + self._pop_next_cursor_diff( + forwards_history, + current_filename, current_cursor)) + if current_cursor is None: + # Went too far, back up once + current_filename, current_cursor = ( + backwards_history.pop()) + return current_filename, current_cursor + + + def __move_cursor_position(self, index_move): + """ + Move the cursor position forward or backward in the cursor + position history by the specified index increment. + """ + self.__ignore_cursor_history = True + # Remove last position as it will be replaced by the current position + if self.cursor_undo_history: + self.cursor_undo_history.pop() + + # Update last position on the line + current_filename = self.get_current_filename() + current_cursor = self.get_current_editor().textCursor() + + if index_move < 0: + # Undo + current_filename, current_cursor = self._history_steps( + -index_move, + self.cursor_redo_history, + self.cursor_undo_history, + current_filename, current_cursor) + + else: + # Redo + current_filename, current_cursor = self._history_steps( + index_move, + self.cursor_undo_history, + self.cursor_redo_history, + current_filename, current_cursor) + + # Place current cursor in history + self.cursor_undo_history.append( + (current_filename, current_cursor)) + filenames = self.get_current_editorstack().get_filenames() + if (not osp.isfile(current_filename) + and current_filename not in filenames): + self.cursor_undo_history.pop() + else: + self.load(current_filename) + editor = self.get_current_editor() + editor.setTextCursor(current_cursor) + editor.ensureCursorVisible() + self.__ignore_cursor_history = False + self.update_cursorpos_actions() + + @Slot() + def go_to_previous_cursor_position(self): + self.__ignore_cursor_history = True + self.switch_to_plugin() + self.__move_cursor_position(-1) + + @Slot() + def go_to_next_cursor_position(self): + self.__ignore_cursor_history = True + self.switch_to_plugin() + self.__move_cursor_position(1) + + @Slot() + def go_to_line(self, line=None): + """Open 'go to line' dialog""" + if isinstance(line, bool): + line = None + editorstack = self.get_current_editorstack() + if editorstack is not None: + editorstack.go_to_line(line) + + @Slot() + def set_or_clear_breakpoint(self): + """Set/Clear breakpoint""" + editorstack = self.get_current_editorstack() + if editorstack is not None: + self.switch_to_plugin() + editorstack.set_or_clear_breakpoint() + + @Slot() + def set_or_edit_conditional_breakpoint(self): + """Set/Edit conditional breakpoint""" + editorstack = self.get_current_editorstack() + if editorstack is not None: + self.switch_to_plugin() + editorstack.set_or_edit_conditional_breakpoint() + + @Slot() + def clear_all_breakpoints(self): + """Clear breakpoints in all files""" + self.switch_to_plugin() + clear_all_breakpoints() + self.breakpoints_saved.emit() + editorstack = self.get_current_editorstack() + if editorstack is not None: + for data in editorstack.data: + data.editor.debugger.clear_breakpoints() + self.refresh_plugin() + + def clear_breakpoint(self, filename, lineno): + """Remove a single breakpoint""" + clear_breakpoint(filename, lineno) + self.breakpoints_saved.emit() + editorstack = self.get_current_editorstack() + if editorstack is not None: + index = self.is_file_opened(filename) + if index is not None: + editorstack.data[index].editor.debugger.toogle_breakpoint( + lineno) + + def stop_debugging(self): + """Stop debugging""" + ipyconsole = self.main.get_plugin(Plugins.IPythonConsole, error=False) + if ipyconsole: + ipyconsole.stop_debugging() + + def debug_command(self, command): + """Debug actions""" + self.switch_to_plugin() + ipyconsole = self.main.get_plugin(Plugins.IPythonConsole, error=False) + if ipyconsole: + ipyconsole.pdb_execute_command(command) + ipyconsole.switch_to_plugin() + + # ----- Handlers for the IPython Console kernels + def _get_editorstack(self): + """ + Get the current editorstack. + + Raises an exception in case no editorstack is found + """ + editorstack = self.get_current_editorstack() + if editorstack is None: + raise RuntimeError('No editorstack found.') + + return editorstack + + def _get_editor(self, filename): + """Get editor for filename and set it as the current editor.""" + editorstack = self._get_editorstack() + if editorstack is None: + return None + + if not filename: + return None + + index = editorstack.has_filename(filename) + if index is None: + return None + + return editorstack.data[index].editor + + def handle_run_cell(self, cell_name, filename): + """ + Get cell code from cell name and file name. + """ + editorstack = self._get_editorstack() + editor = self._get_editor(filename) + + if editor is None: + raise RuntimeError( + "File {} not open in the editor".format(filename)) + + editorstack.last_cell_call = (filename, cell_name) + + # The file is open, load code from editor + return editor.get_cell_code(cell_name) + + def handle_cell_count(self, filename): + """Get number of cells in file to loop.""" + editor = self._get_editor(filename) + + if editor is None: + raise RuntimeError( + "File {} not open in the editor".format(filename)) + + # The file is open, get cell count from editor + return editor.get_cell_count() + + def handle_current_filename(self, filename): + """Get the current filename.""" + return self._get_editorstack().get_current_finfo().filename + + def handle_get_file_code(self, filename, save_all=True): + """ + Return the bytes that compose the file. + + Bytes are returned instead of str to support non utf-8 files. + """ + editorstack = self._get_editorstack() + if save_all and CONF.get( + 'editor', 'save_all_before_run', default=True): + editorstack.save_all(save_new_files=False) + editor = self._get_editor(filename) + + if editor is None: + # Load it from file instead + text, _enc = encoding.read(filename) + return text + + return editor.toPlainText() + + #------ Run Python script + @Slot() + def edit_run_configurations(self): + dialog = RunConfigDialog(self) + dialog.size_change.connect(lambda s: self.set_dialog_size(s)) + if self.dialog_size is not None: + dialog.resize(self.dialog_size) + fname = osp.abspath(self.get_current_filename()) + dialog.setup(fname) + if dialog.exec_(): + fname = dialog.file_to_run + if fname is not None: + self.load(fname) + self.run_file() + + @Slot() + def run_file(self, debug=False): + """Run script inside current interpreter or in a new one""" + editorstack = self.get_current_editorstack() + + editor = self.get_current_editor() + fname = osp.abspath(self.get_current_filename()) + + # Get fname's dirname before we escape the single and double + # quotes. Fixes spyder-ide/spyder#6771. + dirname = osp.dirname(fname) + + # Escape single and double quotes in fname and dirname. + # Fixes spyder-ide/spyder#2158. + fname = fname.replace("'", r"\'").replace('"', r'\"') + dirname = dirname.replace("'", r"\'").replace('"', r'\"') + + runconf = get_run_configuration(fname) + if runconf is None: + dialog = RunConfigOneDialog(self) + dialog.size_change.connect(lambda s: self.set_dialog_size(s)) + if self.dialog_size is not None: + dialog.resize(self.dialog_size) + dialog.setup(fname) + if CONF.get('run', 'open_at_least_once', + not running_under_pytest()): + # Open Run Config dialog at least once: the first time + # a script is ever run in Spyder, so that the user may + # see it at least once and be conscious that it exists + show_dlg = True + CONF.set('run', 'open_at_least_once', False) + else: + # Open Run Config dialog only + # if ALWAYS_OPEN_FIRST_RUN_OPTION option is enabled + show_dlg = CONF.get('run', ALWAYS_OPEN_FIRST_RUN_OPTION) + if show_dlg and not dialog.exec_(): + return + runconf = dialog.get_configuration() + + if runconf.default: + # use global run preferences settings + runconf = RunConfiguration() + + args = runconf.get_arguments() + python_args = runconf.get_python_arguments() + interact = runconf.interact + post_mortem = runconf.post_mortem + current = runconf.current + systerm = runconf.systerm + clear_namespace = runconf.clear_namespace + console_namespace = runconf.console_namespace + + if runconf.file_dir: + wdir = dirname + elif runconf.cw_dir: + wdir = '' + elif osp.isdir(runconf.dir): + wdir = runconf.dir + else: + wdir = '' + + python = True # Note: in the future, it may be useful to run + # something in a terminal instead of a Python interp. + self.__last_ec_exec = (fname, wdir, args, interact, debug, + python, python_args, current, systerm, + post_mortem, clear_namespace, + console_namespace) + self.re_run_file(save_new_files=False) + if not interact and not debug: + # If external console dockwidget is hidden, it will be + # raised in top-level and so focus will be given to the + # current external shell automatically + # (see SpyderPluginWidget.visibility_changed method) + editor.setFocus() + + def set_dialog_size(self, size): + self.dialog_size = size + + @Slot() + def debug_file(self): + """Debug current script""" + self.switch_to_plugin() + current_editor = self.get_current_editor() + if current_editor is not None: + current_editor.sig_debug_start.emit() + self.run_file(debug=True) + + @Slot() + def re_run_file(self, save_new_files=True): + """Re-run last script""" + if self.get_option('save_all_before_run'): + all_saved = self.save_all(save_new_files=save_new_files) + if all_saved is not None and not all_saved: + return + if self.__last_ec_exec is None: + return + (fname, wdir, args, interact, debug, + python, python_args, current, systerm, + post_mortem, clear_namespace, + console_namespace) = self.__last_ec_exec + if not systerm: + self.run_in_current_ipyclient.emit(fname, wdir, args, + debug, post_mortem, + current, clear_namespace, + console_namespace) + else: + self.main.open_external_console(fname, wdir, args, interact, + debug, python, python_args, + systerm, post_mortem) + + @Slot() + def run_selection(self): + """Run selection or current line in external console""" + editorstack = self.get_current_editorstack() + editorstack.run_selection() + + @Slot() + def run_to_line(self): + """Run all lines from beginning up to current line""" + editorstack = self.get_current_editorstack() + editorstack.run_to_line() + + @Slot() + def run_from_line(self): + """Run all lines from current line to end""" + editorstack = self.get_current_editorstack() + editorstack.run_from_line() + + @Slot() + def run_cell(self): + """Run current cell""" + editorstack = self.get_current_editorstack() + editorstack.run_cell() + + @Slot() + def run_cell_and_advance(self): + """Run current cell and advance to the next one""" + editorstack = self.get_current_editorstack() + editorstack.run_cell_and_advance() + + @Slot() + def debug_cell(self): + '''Debug Current cell.''' + editorstack = self.get_current_editorstack() + editorstack.debug_cell() + + @Slot() + def re_run_last_cell(self): + """Run last executed cell.""" + editorstack = self.get_current_editorstack() + editorstack.re_run_last_cell() + + # ------ Code bookmarks + @Slot(int) + def save_bookmark(self, slot_num): + """Save current line and position as bookmark.""" + bookmarks = CONF.get('editor', 'bookmarks') + editorstack = self.get_current_editorstack() + if slot_num in bookmarks: + filename, line_num, column = bookmarks[slot_num] + if osp.isfile(filename): + index = editorstack.has_filename(filename) + if index is not None: + block = (editorstack.tabs.widget(index).document() + .findBlockByNumber(line_num)) + block.userData().bookmarks.remove((slot_num, column)) + if editorstack is not None: + self.switch_to_plugin() + editorstack.set_bookmark(slot_num) + + @Slot(int) + def load_bookmark(self, slot_num): + """Set cursor to bookmarked file and position.""" + bookmarks = CONF.get('editor', 'bookmarks') + if slot_num in bookmarks: + filename, line_num, column = bookmarks[slot_num] + else: + return + if not osp.isfile(filename): + self.last_edit_cursor_pos = None + return + self.load(filename) + editor = self.get_current_editor() + if line_num < editor.document().lineCount(): + linelength = len(editor.document() + .findBlockByNumber(line_num).text()) + if column <= linelength: + editor.go_to_line(line_num + 1, column) + else: + # Last column + editor.go_to_line(line_num + 1, linelength) + + #------ Zoom in/out/reset + def zoom(self, factor): + """Zoom in/out/reset""" + editor = self.get_current_editorstack().get_current_editor() + if factor == 0: + font = self.get_font() + editor.set_font(font) + else: + font = editor.font() + size = font.pointSize() + factor + if size > 0: + font.setPointSize(size) + editor.set_font(font) + editor.update_tab_stop_width_spaces() + + #------ Options + def apply_plugin_settings(self, options): + """Apply configuration file's plugin settings""" + if self.editorstacks is not None: + # --- syntax highlight and text rendering settings + currentline_n = 'highlight_current_line' + currentline_o = self.get_option(currentline_n) + currentcell_n = 'highlight_current_cell' + currentcell_o = self.get_option(currentcell_n) + occurrence_n = 'occurrence_highlighting' + occurrence_o = self.get_option(occurrence_n) + occurrence_timeout_n = 'occurrence_highlighting/timeout' + occurrence_timeout_o = self.get_option(occurrence_timeout_n) + focus_to_editor_n = 'focus_to_editor' + focus_to_editor_o = self.get_option(focus_to_editor_n) + + for editorstack in self.editorstacks: + if currentline_n in options: + editorstack.set_highlight_current_line_enabled( + currentline_o) + if currentcell_n in options: + editorstack.set_highlight_current_cell_enabled( + currentcell_o) + if occurrence_n in options: + editorstack.set_occurrence_highlighting_enabled(occurrence_o) + if occurrence_timeout_n in options: + editorstack.set_occurrence_highlighting_timeout( + occurrence_timeout_o) + if focus_to_editor_n in options: + editorstack.set_focus_to_editor(focus_to_editor_o) + + # --- everything else + tabbar_n = 'show_tab_bar' + tabbar_o = self.get_option(tabbar_n) + classfuncdropdown_n = 'show_class_func_dropdown' + classfuncdropdown_o = self.get_option(classfuncdropdown_n) + linenb_n = 'line_numbers' + linenb_o = self.get_option(linenb_n) + blanks_n = 'blank_spaces' + blanks_o = self.get_option(blanks_n) + scrollpastend_n = 'scroll_past_end' + scrollpastend_o = self.get_option(scrollpastend_n) + wrap_n = 'wrap' + wrap_o = self.get_option(wrap_n) + indentguides_n = 'indent_guides' + indentguides_o = self.get_option(indentguides_n) + codefolding_n = 'code_folding' + codefolding_o = self.get_option(codefolding_n) + tabindent_n = 'tab_always_indent' + tabindent_o = self.get_option(tabindent_n) + stripindent_n = 'strip_trailing_spaces_on_modify' + stripindent_o = self.get_option(stripindent_n) + ibackspace_n = 'intelligent_backspace' + ibackspace_o = self.get_option(ibackspace_n) + removetrail_n = 'always_remove_trailing_spaces' + removetrail_o = self.get_option(removetrail_n) + add_newline_n = 'add_newline' + add_newline_o = self.get_option(add_newline_n) + removetrail_newlines_n = 'always_remove_trailing_newlines' + removetrail_newlines_o = self.get_option(removetrail_newlines_n) + converteol_n = 'convert_eol_on_save' + converteol_o = self.get_option(converteol_n) + converteolto_n = 'convert_eol_on_save_to' + converteolto_o = self.get_option(converteolto_n) + runcellcopy_n = 'run_cell_copy' + runcellcopy_o = self.get_option(runcellcopy_n) + closepar_n = 'close_parentheses' + closepar_o = self.get_option(closepar_n) + close_quotes_n = 'close_quotes' + close_quotes_o = self.get_option(close_quotes_n) + add_colons_n = 'add_colons' + add_colons_o = self.get_option(add_colons_n) + autounindent_n = 'auto_unindent' + autounindent_o = self.get_option(autounindent_n) + indent_chars_n = 'indent_chars' + indent_chars_o = self.get_option(indent_chars_n) + tab_stop_width_spaces_n = 'tab_stop_width_spaces' + tab_stop_width_spaces_o = self.get_option(tab_stop_width_spaces_n) + help_n = 'connect_to_oi' + help_o = CONF.get('help', 'connect/editor') + todo_n = 'todo_list' + todo_o = self.get_option(todo_n) + + finfo = self.get_current_finfo() + + for editorstack in self.editorstacks: + # Checkable options + if blanks_n in options: + editorstack.set_blanks_enabled(blanks_o) + if scrollpastend_n in options: + editorstack.set_scrollpastend_enabled(scrollpastend_o) + if indentguides_n in options: + editorstack.set_indent_guides(indentguides_o) + if codefolding_n in options: + editorstack.set_code_folding_enabled(codefolding_o) + if classfuncdropdown_n in options: + editorstack.set_classfunc_dropdown_visible( + classfuncdropdown_o) + if tabbar_n in options: + editorstack.set_tabbar_visible(tabbar_o) + if linenb_n in options: + editorstack.set_linenumbers_enabled(linenb_o, + current_finfo=finfo) + if wrap_n in options: + editorstack.set_wrap_enabled(wrap_o) + if tabindent_n in options: + editorstack.set_tabmode_enabled(tabindent_o) + if stripindent_n in options: + editorstack.set_stripmode_enabled(stripindent_o) + if ibackspace_n in options: + editorstack.set_intelligent_backspace_enabled(ibackspace_o) + if removetrail_n in options: + editorstack.set_always_remove_trailing_spaces(removetrail_o) + if add_newline_n in options: + editorstack.set_add_newline(add_newline_o) + if removetrail_newlines_n in options: + editorstack.set_remove_trailing_newlines( + removetrail_newlines_o) + if converteol_n in options: + editorstack.set_convert_eol_on_save(converteol_o) + if converteolto_n in options: + editorstack.set_convert_eol_on_save_to(converteolto_o) + if runcellcopy_n in options: + editorstack.set_run_cell_copy(runcellcopy_o) + if closepar_n in options: + editorstack.set_close_parentheses_enabled(closepar_o) + if close_quotes_n in options: + editorstack.set_close_quotes_enabled(close_quotes_o) + if add_colons_n in options: + editorstack.set_add_colons_enabled(add_colons_o) + if autounindent_n in options: + editorstack.set_auto_unindent_enabled(autounindent_o) + if indent_chars_n in options: + editorstack.set_indent_chars(indent_chars_o) + if tab_stop_width_spaces_n in options: + editorstack.set_tab_stop_width_spaces(tab_stop_width_spaces_o) + if help_n in options: + editorstack.set_help_enabled(help_o) + if todo_n in options: + editorstack.set_todolist_enabled(todo_o, + current_finfo=finfo) + + for name, action in self.checkable_actions.items(): + if name in options: + # Avoid triggering the action when this action changes state + action.blockSignals(True) + state = self.get_option(name) + action.setChecked(state) + action.blockSignals(False) + # See: spyder-ide/spyder#9915 + + # Multiply by 1000 to convert seconds to milliseconds + self.autosave.interval = ( + self.get_option('autosave_interval') * 1000) + self.autosave.enabled = self.get_option('autosave_enabled') + + # We must update the current editor after the others: + # (otherwise, code analysis buttons state would correspond to the + # last editor instead of showing the one of the current editor) + if finfo is not None: + if todo_n in options and todo_o: + finfo.run_todo_finder() + + @on_conf_change(option='edge_line') + def set_edgeline_enabled(self, value): + if self.editorstacks is not None: + logger.debug(f"Set edge line to {value}") + for editorstack in self.editorstacks: + editorstack.set_edgeline_enabled(value) + + @on_conf_change( + option=('provider_configuration', 'lsp', 'values', + 'pycodestyle/max_line_length'), + section='completions' + ) + def set_edgeline_columns(self, value): + if self.editorstacks is not None: + logger.debug(f"Set edge line columns to {value}") + for editorstack in self.editorstacks: + editorstack.set_edgeline_columns(value) + + @on_conf_change(option='enable_code_snippets', section='completions') + def set_code_snippets_enabled(self, value): + if self.editorstacks is not None: + logger.debug(f"Set code snippets to {value}") + for editorstack in self.editorstacks: + editorstack.set_code_snippets_enabled(value) + + @on_conf_change(option='automatic_completions') + def set_automatic_completions_enabled(self, value): + if self.editorstacks is not None: + logger.debug(f"Set automatic completions to {value}") + for editorstack in self.editorstacks: + editorstack.set_automatic_completions_enabled(value) + + @on_conf_change(option='automatic_completions_after_chars') + def set_automatic_completions_after_chars(self, value): + if self.editorstacks is not None: + logger.debug(f"Set chars for automatic completions to {value}") + for editorstack in self.editorstacks: + editorstack.set_automatic_completions_after_chars(value) + + @on_conf_change(option='automatic_completions_after_ms') + def set_automatic_completions_after_ms(self, value): + if self.editorstacks is not None: + logger.debug(f"Set automatic completions after {value} ms") + for editorstack in self.editorstacks: + editorstack.set_automatic_completions_after_ms(value) + + @on_conf_change(option='completions_hint') + def set_completions_hint_enabled(self, value): + if self.editorstacks is not None: + logger.debug(f"Set completions hint to {value}") + for editorstack in self.editorstacks: + editorstack.set_completions_hint_enabled(value) + + @on_conf_change(option='completions_hint_after_ms') + def set_completions_hint_after_ms(self, value): + if self.editorstacks is not None: + logger.debug(f"Set completions hint after {value} ms") + for editorstack in self.editorstacks: + editorstack.set_completions_hint_after_ms(value) + + @on_conf_change( + option=('provider_configuration', 'lsp', 'values', + 'enable_hover_hints'), + section='completions' + ) + def set_hover_hints_enabled(self, value): + if self.editorstacks is not None: + logger.debug(f"Set hover hints to {value}") + for editorstack in self.editorstacks: + editorstack.set_hover_hints_enabled(value) + + @on_conf_change( + option=('provider_configuration', 'lsp', 'values', 'format_on_save'), + section='completions' + ) + def set_format_on_save(self, value): + if self.editorstacks is not None: + logger.debug(f"Set format on save to {value}") + for editorstack in self.editorstacks: + editorstack.set_format_on_save(value) + + @on_conf_change(option='underline_errors') + def set_underline_errors_enabled(self, value): + if self.editorstacks is not None: + logger.debug(f"Set underline errors to {value}") + for editorstack in self.editorstacks: + editorstack.set_underline_errors_enabled(value) + + @on_conf_change(option='selected', section='appearance') + def set_color_scheme(self, value): + if self.editorstacks is not None: + logger.debug(f"Set color scheme to {value}") + for editorstack in self.editorstacks: + editorstack.set_color_scheme(value) + + # --- Open files + def get_open_filenames(self): + """Get the list of open files in the current stack""" + editorstack = self.editorstacks[0] + filenames = [] + filenames += [finfo.filename for finfo in editorstack.data] + return filenames + + def set_open_filenames(self): + """ + Set the recent opened files on editor based on active project. + + If no project is active, then editor filenames are saved, otherwise + the opened filenames are stored in the project config info. + """ + if self.projects is not None: + if not self.projects.get_active_project(): + filenames = self.get_open_filenames() + self.set_option('filenames', filenames) + + def setup_open_files(self, close_previous_files=True): + """ + Open the list of saved files per project. + + Also open any files that the user selected in the recovery dialog. + """ + self.set_create_new_file_if_empty(False) + active_project_path = None + if self.projects is not None: + active_project_path = self.projects.get_active_project_path() + + if active_project_path: + filenames = self.projects.get_project_filenames() + else: + filenames = self.get_option('filenames', default=[]) + + if close_previous_files: + self.close_all_files() + + all_filenames = self.autosave.recover_files_to_open + filenames + if all_filenames and any([osp.isfile(f) for f in all_filenames]): + layout = self.get_option('layout_settings', None) + # Check if no saved layout settings exist, e.g. clean prefs file. + # If not, load with default focus/layout, to fix + # spyder-ide/spyder#8458. + if layout: + is_vertical, cfname, clines = layout.get('splitsettings')[0] + # Check that a value for current line exist for each filename + # in the available settings. See spyder-ide/spyder#12201 + if cfname in filenames and len(filenames) == len(clines): + index = filenames.index(cfname) + # First we load the last focused file. + self.load(filenames[index], goto=clines[index], set_focus=True) + # Then we load the files located to the left of the last + # focused file in the tabbar, while keeping the focus on + # the last focused file. + if index > 0: + self.load(filenames[index::-1], goto=clines[index::-1], + set_focus=False, add_where='start') + # Then we load the files located to the right of the last + # focused file in the tabbar, while keeping the focus on + # the last focused file. + if index < (len(filenames) - 1): + self.load(filenames[index+1:], goto=clines[index:], + set_focus=False, add_where='end') + # Finally we load any recovered files at the end of the tabbar, + # while keeping focus on the last focused file. + if self.autosave.recover_files_to_open: + self.load(self.autosave.recover_files_to_open, + set_focus=False, add_where='end') + else: + if filenames: + self.load(filenames, goto=clines) + if self.autosave.recover_files_to_open: + self.load(self.autosave.recover_files_to_open) + else: + if filenames: + self.load(filenames) + if self.autosave.recover_files_to_open: + self.load(self.autosave.recover_files_to_open) + + if self.__first_open_files_setup: + self.__first_open_files_setup = False + if layout is not None: + self.editorsplitter.set_layout_settings( + layout, + dont_goto=filenames[0]) + win_layout = self.get_option('windows_layout_settings', []) + if win_layout: + for layout_settings in win_layout: + self.editorwindows_to_be_created.append( + layout_settings) + self.set_last_focused_editorstack(self, self.editorstacks[0]) + + # This is necessary to update the statusbar widgets after files + # have been loaded. + editorstack = self.get_current_editorstack() + if editorstack: + self.get_current_editorstack().refresh() + else: + self.__load_temp_file() + self.set_create_new_file_if_empty(True) + self.sig_open_files_finished.emit() + + def save_open_files(self): + """Save the list of open files""" + self.set_option('filenames', self.get_open_filenames()) + + def set_create_new_file_if_empty(self, value): + """Change the value of create_new_file_if_empty""" + for editorstack in self.editorstacks: + editorstack.create_new_file_if_empty = value + + # --- File Menu actions (Mac only) + @Slot() + def go_to_next_file(self): + """Switch to next file tab on the current editor stack.""" + editorstack = self.get_current_editorstack() + editorstack.tabs.tab_navigate(+1) + + @Slot() + def go_to_previous_file(self): + """Switch to previous file tab on the current editor stack.""" + editorstack = self.get_current_editorstack() + editorstack.tabs.tab_navigate(-1) + + def set_current_project_path(self, root_path=None): + """ + Set the current active project root path. + + Parameters + ---------- + root_path: str or None, optional + Path to current project root path. Default is None. + """ + for editorstack in self.editorstacks: + editorstack.set_current_project_path(root_path) + + def register_panel(self, panel_class, *args, position=Panel.Position.LEFT, + **kwargs): + """Register a panel in all the editorstacks in the given position.""" + for editorstack in self.editorstacks: + editorstack.register_panel( + panel_class, *args, position=position, **kwargs) + + # TODO: To be updated after migration + def on_mainwindow_visible(self): + return diff --git a/spyder/plugins/editor/utils/findtasks.py b/spyder/plugins/editor/utils/findtasks.py index 87026d35323..1e7ce44716c 100644 --- a/spyder/plugins/editor/utils/findtasks.py +++ b/spyder/plugins/editor/utils/findtasks.py @@ -1,33 +1,33 @@ -# -*- coding: utf-8 -*- -# -# Copyright © Spyder Project Contributors -# Licensed under the terms of the MIT License -# (see spyder/__init__.py for details) - -""" -Source code analysis utilities. -""" - -import re - -# Local import -from spyder.config.base import get_debug_level - -DEBUG_EDITOR = get_debug_level() >= 3 - -# ============================================================================= -# Find tasks - TODOs -# ============================================================================= -TASKS_PATTERN = r"(^|#)[ ]*(TODO|FIXME|XXX|HINT|TIP|@todo|" \ - r"HACK|BUG|OPTIMIZE|!!!|\?\?\?)([^#]*)" - - -def find_tasks(source_code): - """Find tasks in source code (TODO, FIXME, XXX, ...).""" - results = [] - for line, text in enumerate(source_code.splitlines()): - for todo in re.findall(TASKS_PATTERN, text): - todo_text = (todo[-1].strip(' :').capitalize() if todo[-1] - else todo[-2]) - results.append((todo_text, line + 1)) - return results +# -*- coding: utf-8 -*- +# +# Copyright © Spyder Project Contributors +# Licensed under the terms of the MIT License +# (see spyder/__init__.py for details) + +""" +Source code analysis utilities. +""" + +import re + +# Local import +from spyder.config.base import get_debug_level + +DEBUG_EDITOR = get_debug_level() >= 3 + +# ============================================================================= +# Find tasks - TODOs +# ============================================================================= +TASKS_PATTERN = r"(^|#)[ ]*(TODO|FIXME|XXX|HINT|TIP|@todo|" \ + r"HACK|BUG|OPTIMIZE|!!!|\?\?\?)([^#]*)" + + +def find_tasks(source_code): + """Find tasks in source code (TODO, FIXME, XXX, ...).""" + results = [] + for line, text in enumerate(source_code.splitlines()): + for todo in re.findall(TASKS_PATTERN, text): + todo_text = (todo[-1].strip(' :').capitalize() if todo[-1] + else todo[-2]) + results.append((todo_text, line + 1)) + return results diff --git a/spyder/plugins/editor/widgets/base.py b/spyder/plugins/editor/widgets/base.py index 07baa7a0715..ae797a29b01 100644 --- a/spyder/plugins/editor/widgets/base.py +++ b/spyder/plugins/editor/widgets/base.py @@ -1,1157 +1,1157 @@ -# -*- coding: utf-8 -*- -# -# Copyright © Spyder Project Contributors -# Licensed under the terms of the MIT License -# (see spyder/__init__.py for details) - -"""QPlainTextEdit base class""" - -# pylint: disable=C0103 -# pylint: disable=R0903 -# pylint: disable=R0911 -# pylint: disable=R0201 - -# Standard library imports -import os -import sys - -# Third party imports -from qtpy.compat import to_qvariant -from qtpy.QtCore import QEvent, QPoint, Qt, Signal, Slot -from qtpy.QtGui import (QClipboard, QColor, QMouseEvent, QTextFormat, - QTextOption, QTextCursor) -from qtpy.QtWidgets import QApplication, QMainWindow, QPlainTextEdit, QToolTip - -# Local imports -from spyder.config.gui import get_font -from spyder.config.manager import CONF -from spyder.py3compat import PY3, to_text_string -from spyder.widgets.calltip import CallTipWidget, ToolTipWidget -from spyder.widgets.mixins import BaseEditMixin -from spyder.plugins.editor.api.decoration import TextDecoration, DRAW_ORDERS -from spyder.plugins.editor.utils.decoration import TextDecorationsManager -from spyder.plugins.editor.widgets.completion import CompletionWidget -from spyder.plugins.outlineexplorer.api import is_cell_header, document_cells -from spyder.utils.palette import SpyderPalette - -class TextEditBaseWidget(QPlainTextEdit, BaseEditMixin): - """Text edit base widget""" - BRACE_MATCHING_SCOPE = ('sof', 'eof') - focus_in = Signal() - zoom_in = Signal() - zoom_out = Signal() - zoom_reset = Signal() - focus_changed = Signal() - sig_insert_completion = Signal(str) - sig_eol_chars_changed = Signal(str) - sig_prev_cursor = Signal() - sig_next_cursor = Signal() - - def __init__(self, parent=None): - QPlainTextEdit.__init__(self, parent) - BaseEditMixin.__init__(self) - - self.has_cell_separators = False - self.setAttribute(Qt.WA_DeleteOnClose) - - self._restore_selection_pos = None - - # Trailing newlines/spaces trimming - self.remove_trailing_spaces = False - self.remove_trailing_newlines = False - - # Add a new line when saving - self.add_newline = False - - # Code snippets - self.code_snippets = True - - self.cursorPositionChanged.connect(self.cursor_position_changed) - - self.indent_chars = " "*4 - self.tab_stop_width_spaces = 4 - - # Code completion / calltips - if parent is not None: - mainwin = parent - while not isinstance(mainwin, QMainWindow): - mainwin = mainwin.parent() - if mainwin is None: - break - if mainwin is not None: - parent = mainwin - - self.completion_widget = CompletionWidget(self, parent) - self.codecompletion_auto = False - self.setup_completion() - - self.calltip_widget = CallTipWidget(self, hide_timer_on=False) - self.tooltip_widget = ToolTipWidget(self, as_tooltip=True) - - self.highlight_current_cell_enabled = False - - # The color values may be overridden by the syntax highlighter - # Highlight current line color - self.currentline_color = QColor( - SpyderPalette.COLOR_ERROR_2).lighter(190) - self.currentcell_color = QColor( - SpyderPalette.COLOR_ERROR_2).lighter(194) - - # Brace matching - self.bracepos = None - self.matched_p_color = QColor(SpyderPalette.COLOR_SUCCESS_1) - self.unmatched_p_color = QColor(SpyderPalette.COLOR_ERROR_2) - - self.decorations = TextDecorationsManager(self) - - # Save current cell. This is invalidated as soon as the text changes. - # Useful to avoid recomputing while scrolling. - self.current_cell = None - - def reset_current_cell(): - self.current_cell = None - self.highlight_current_cell() - - self.textChanged.connect(reset_current_cell) - - # Cache - self._current_cell_cursor = None - self._current_line_block = None - - def setup_completion(self): - size = CONF.get('main', 'completion/size') - font = get_font() - self.completion_widget.setup_appearance(size, font) - - def set_indent_chars(self, indent_chars): - self.indent_chars = indent_chars - - def set_tab_stop_width_spaces(self, tab_stop_width_spaces): - self.tab_stop_width_spaces = tab_stop_width_spaces - self.update_tab_stop_width_spaces() - - def set_remove_trailing_spaces(self, flag): - self.remove_trailing_spaces = flag - - def set_add_newline(self, add_newline): - self.add_newline = add_newline - - def set_remove_trailing_newlines(self, flag): - self.remove_trailing_newlines = flag - - def update_tab_stop_width_spaces(self): - self.setTabStopWidth(self.fontMetrics().width( - ' ' * self.tab_stop_width_spaces)) - - def set_palette(self, background, foreground): - """ - Set text editor palette colors: - background color and caret (text cursor) color - """ - # Because QtStylsheet overrides QPalette and because some style do not - # use the palette for all drawing (e.g. macOS styles), the background - # and foreground color of each TextEditBaseWidget instance must be set - # with a stylesheet extended with an ID Selector. - # Fixes spyder-ide/spyder#2028, spyder-ide/spyder#8069 and - # spyder-ide/spyder#9248. - if not self.objectName(): - self.setObjectName(self.__class__.__name__ + str(id(self))) - style = "QPlainTextEdit#%s {background: %s; color: %s;}" % \ - (self.objectName(), background.name(), foreground.name()) - self.setStyleSheet(style) - - # ---- Extra selections - def get_extra_selections(self, key): - """Return editor extra selections. - - Args: - key (str) name of the extra selections group - - Returns: - list of sourcecode.api.TextDecoration. - """ - return self.decorations.get(key, []) - - def set_extra_selections(self, key, extra_selections): - """Set extra selections for a key. - - Also assign draw orders to leave current_cell and current_line - in the background (and avoid them to cover other decorations) - - NOTE: This will remove previous decorations added to the same key. - - Args: - key (str) name of the extra selections group. - extra_selections (list of sourcecode.api.TextDecoration). - """ - # use draw orders to highlight current_cell and current_line first - draw_order = DRAW_ORDERS.get(key) - if draw_order is None: - draw_order = DRAW_ORDERS.get('on_top') - - for selection in extra_selections: - selection.draw_order = draw_order - selection.kind = key - - self.decorations.add_key(key, extra_selections) - self.update() - - def clear_extra_selections(self, key): - """Remove decorations added through set_extra_selections. - - Args: - key (str) name of the extra selections group. - """ - self.decorations.remove_key(key) - self.update() - - def get_visible_block_numbers(self): - """Get the first and last visible block numbers.""" - first = self.firstVisibleBlock().blockNumber() - bottom_right = QPoint(self.viewport().width() - 1, - self.viewport().height() - 1) - last = self.cursorForPosition(bottom_right).blockNumber() - return (first, last) - - def get_buffer_block_numbers(self): - """ - Get the first and last block numbers of a region that covers - the visible one plus a buffer of half that region above and - below to make more fluid certain operations. - """ - first_visible, last_visible = self.get_visible_block_numbers() - buffer_height = round((last_visible - first_visible) / 2) - - first = first_visible - buffer_height - first = 0 if first < 0 else first - - last = last_visible + buffer_height - last = self.blockCount() if last > self.blockCount() else last - - return (first, last) - - # ------Highlight current line - def highlight_current_line(self): - """Highlight current line""" - cursor = self.textCursor() - block = cursor.block() - if self._current_line_block == block: - return - self._current_line_block = block - selection = TextDecoration(cursor) - selection.format.setProperty(QTextFormat.FullWidthSelection, - to_qvariant(True)) - selection.format.setBackground(self.currentline_color) - selection.cursor.clearSelection() - self.set_extra_selections('current_line', [selection]) - - def unhighlight_current_line(self): - """Unhighlight current line""" - self._current_line_block = None - self.clear_extra_selections('current_line') - - # ------Highlight current cell - def highlight_current_cell(self): - """Highlight current cell""" - if (not self.has_cell_separators or - not self.highlight_current_cell_enabled): - self._current_cell_cursor = None - return - cursor, whole_file_selected = self.select_current_cell() - - def same_selection(c1, c2): - if c1 is None or c2 is None: - return False - return ( - c1.selectionStart() == c2.selectionStart() and - c1.selectionEnd() == c2.selectionEnd() - ) - - if same_selection(self._current_cell_cursor, cursor): - # Already correct - return - self._current_cell_cursor = cursor - selection = TextDecoration(cursor) - selection.format.setProperty(QTextFormat.FullWidthSelection, - to_qvariant(True)) - selection.format.setBackground(self.currentcell_color) - - if whole_file_selected: - self.clear_extra_selections('current_cell') - else: - self.set_extra_selections('current_cell', [selection]) - - def unhighlight_current_cell(self): - """Unhighlight current cell""" - self._current_cell_cursor = None - self.clear_extra_selections('current_cell') - - def in_comment(self, cursor=None, position=None): - """Returns True if the given position is inside a comment. - - Trivial default implementation. To be overridden by subclass. - This function is used to define the default behaviour of - self.find_brace_match. - """ - return False - - def in_string(self, cursor=None, position=None): - """Returns True if the given position is inside a string. - - Trivial default implementation. To be overridden by subclass. - This function is used to define the default behaviour of - self.find_brace_match. - """ - return False - - def find_brace_match(self, position, brace, forward, - ignore_brace=None, stop=None): - """Returns position of matching brace. - - Parameters - ---------- - position : int - The position of the brace to be matched. - brace : {'[', ']', '(', ')', '{', '}'} - The brace character to be matched. - [ <-> ], ( <-> ), { <-> } - forward : boolean - Whether to search forwards or backwards for a match. - ignore_brace : callable taking int returning boolean, optional - Whether to ignore a brace (as function of position). - stop : callable taking int returning boolean, optional - Whether to stop the search early (as function of position). - - If both *ignore_brace* and *stop* are None, then brace matching - is handled differently depending on whether *position* is - inside a string, comment or regular code. If in regular code, - then any braces inside strings and comments are ignored. If in a - string/comment, then only braces in the same string/comment are - considered potential matches. The functions self.in_comment and - self.in_string are used to determine string/comment/code status - of characters in this case. - - If exactly one of *ignore_brace* and *stop* is None, then it is - replaced by a function returning False for every position. I.e.: - lambda pos: False - - Returns - ------- - The position of the matching brace. If no matching brace - exists, then None is returned. - """ - - if ignore_brace is None and stop is None: - if self.in_string(position=position): - # Only search inside the current string - def stop(pos): - return not self.in_string(position=pos) - elif self.in_comment(position=position): - # Only search inside the current comment - def stop(pos): - return not self.in_comment(position=pos) - else: - # Ignore braces inside strings and comments - def ignore_brace(pos): - return (self.in_string(position=pos) or - self.in_comment(position=pos)) - - # Deal with search range and direction - start_pos, end_pos = self.BRACE_MATCHING_SCOPE - if forward: - closing_brace = {'(': ')', '[': ']', '{': '}'}[brace] - text = self.get_text(position, end_pos, remove_newlines=False) - else: - # Handle backwards search with the same code as forwards - # by reversing the string to be searched. - closing_brace = {')': '(', ']': '[', '}': '{'}[brace] - text = self.get_text(start_pos, position+1, remove_newlines=False) - text = text[-1::-1] # reverse - - def ind2pos(index): - """Computes editor position from search index.""" - return (position + index) if forward else (position - index) - - # Search starts at the first position after the given one - # (which is assumed to contain a brace). - i_start_close = 1 - i_start_open = 1 - while True: - i_close = text.find(closing_brace, i_start_close) - i_start_close = i_close+1 # next potential start - if i_close == -1: - return # no matching brace exists - elif ignore_brace is None or not ignore_brace(ind2pos(i_close)): - while True: - i_open = text.find(brace, i_start_open, i_close) - i_start_open = i_open+1 # next potential start - if i_open == -1: - # found matching brace, but should we have - # stopped before this point? - if stop is not None: - # There's room for optimization here... - for i in range(1, i_close+1): - if stop(ind2pos(i)): - return - return ind2pos(i_close) - elif (ignore_brace is None or - not ignore_brace(ind2pos(i_open))): - break # must find new closing brace - - def __highlight(self, positions, color=None, cancel=False): - if cancel: - self.clear_extra_selections('brace_matching') - return - extra_selections = [] - for position in positions: - if position > self.get_position('eof'): - return - selection = TextDecoration(self.textCursor()) - selection.format.setBackground(color) - selection.cursor.clearSelection() - selection.cursor.setPosition(position) - selection.cursor.movePosition(QTextCursor.NextCharacter, - QTextCursor.KeepAnchor) - extra_selections.append(selection) - self.set_extra_selections('brace_matching', extra_selections) - - def cursor_position_changed(self): - """Handle brace matching.""" - # Clear last brace highlight (if any) - if self.bracepos is not None: - self.__highlight(self.bracepos, cancel=True) - self.bracepos = None - - # Get the current cursor position, check if it is at a brace, - # and, if so, determine the direction in which to search for able - # matching brace. - cursor = self.textCursor() - if cursor.position() == 0: - return - cursor.movePosition(QTextCursor.PreviousCharacter, - QTextCursor.KeepAnchor) - text = to_text_string(cursor.selectedText()) - if text in (')', ']', '}'): - forward = False - elif text in ('(', '[', '{'): - forward = True - else: - return - - pos1 = cursor.position() - pos2 = self.find_brace_match(pos1, text, forward=forward) - - # Set a new brace highlight - if pos2 is not None: - self.bracepos = (pos1, pos2) - self.__highlight(self.bracepos, color=self.matched_p_color) - else: - self.bracepos = (pos1,) - self.__highlight(self.bracepos, color=self.unmatched_p_color) - - # -----Widget setup and options - def set_wrap_mode(self, mode=None): - """ - Set wrap mode - Valid *mode* values: None, 'word', 'character' - """ - if mode == 'word': - wrap_mode = QTextOption.WrapAtWordBoundaryOrAnywhere - elif mode == 'character': - wrap_mode = QTextOption.WrapAnywhere - else: - wrap_mode = QTextOption.NoWrap - self.setWordWrapMode(wrap_mode) - - # ------Reimplementing Qt methods - @Slot() - def copy(self): - """ - Reimplement Qt method - Copy text to clipboard with correct EOL chars - """ - if self.get_selected_text(): - QApplication.clipboard().setText(self.get_selected_text()) - - def toPlainText(self): - """ - Reimplement Qt method - Fix PyQt4 bug on Windows and Python 3 - """ - # Fix what appears to be a PyQt4 bug when getting file - # contents under Windows and PY3. This bug leads to - # corruptions when saving files with certain combinations - # of unicode chars on them (like the one attached on - # spyder-ide/spyder#1546). - if os.name == 'nt' and PY3: - text = self.get_text('sof', 'eof') - return text.replace('\u2028', '\n').replace('\u2029', '\n')\ - .replace('\u0085', '\n') - return super(TextEditBaseWidget, self).toPlainText() - - def keyPressEvent(self, event): - key = event.key() - ctrl = event.modifiers() & Qt.ControlModifier - meta = event.modifiers() & Qt.MetaModifier - # Use our own copy method for {Ctrl,Cmd}+C to avoid Qt - # copying text in HTML. See spyder-ide/spyder#2285. - if (ctrl or meta) and key == Qt.Key_C: - self.copy() - else: - super(TextEditBaseWidget, self).keyPressEvent(event) - - # ------Text: get, set, ... - def get_cell_list(self): - """Get all cells.""" - # Reimplemented in childrens - return [] - - def get_selection_as_executable_code(self, cursor=None): - """Return selected text as a processed text, - to be executable in a Python/IPython interpreter""" - ls = self.get_line_separator() - - _indent = lambda line: len(line)-len(line.lstrip()) - - line_from, line_to = self.get_selection_bounds(cursor) - text = self.get_selected_text(cursor) - if not text: - return - - lines = text.split(ls) - if len(lines) > 1: - # Multiline selection -> eventually fixing indentation - original_indent = _indent(self.get_text_line(line_from)) - text = (" "*(original_indent-_indent(lines[0])))+text - - # If there is a common indent to all lines, find it. - # Moving from bottom line to top line ensures that blank - # lines inherit the indent of the line *below* it, - # which is the desired behavior. - min_indent = 999 - current_indent = 0 - lines = text.split(ls) - for i in range(len(lines)-1, -1, -1): - line = lines[i] - if line.strip(): - current_indent = _indent(line) - min_indent = min(current_indent, min_indent) - else: - lines[i] = ' ' * current_indent - if min_indent: - lines = [line[min_indent:] for line in lines] - - # Remove any leading whitespace or comment lines - # since they confuse the reserved word detector that follows below - lines_removed = 0 - while lines: - first_line = lines[0].lstrip() - if first_line == '' or first_line[0] == '#': - lines_removed += 1 - lines.pop(0) - else: - break - - # Add an EOL character after the last line of code so that it gets - # evaluated automatically by the console and any quote characters - # are separated from the triple quotes of runcell - lines.append(ls) - - # Add removed lines back to have correct traceback line numbers - leading_lines_str = ls * lines_removed - - return leading_lines_str + ls.join(lines) - - def get_cell_as_executable_code(self, cursor=None): - """Return cell contents as executable code.""" - if cursor is None: - cursor = self.textCursor() - ls = self.get_line_separator() - cursor, __ = self.select_current_cell(cursor) - line_from, __ = self.get_selection_bounds(cursor) - # Get the block for the first cell line - start = cursor.selectionStart() - block = self.document().findBlock(start) - if not is_cell_header(block) and start > 0: - block = self.document().findBlock(start - 1) - # Get text - text = self.get_selection_as_executable_code(cursor) - if text is not None: - text = ls * line_from + text - return text, block - - def select_current_cell(self, cursor=None): - """ - Select cell under cursor in the visible portion of the file - cell = group of lines separated by CELL_SEPARATORS - returns - -the textCursor - -a boolean indicating if the entire file is selected - """ - if cursor is None: - cursor = self.textCursor() - - if self.current_cell: - current_cell, cell_full_file = self.current_cell - cell_start_pos = current_cell.selectionStart() - cell_end_position = current_cell.selectionEnd() - # Check if the saved current cell is still valid - if cell_start_pos <= cursor.position() < cell_end_position: - return current_cell, cell_full_file - else: - self.current_cell = None - - block = cursor.block() - try: - if is_cell_header(block): - header = block.userData().oedata - else: - header = next(document_cells( - block, forward=False, - cell_list=self.get_cell_list())) - cell_start_pos = header.block.position() - cell_at_file_start = False - cursor.setPosition(cell_start_pos) - except StopIteration: - # This cell has no header, so it is the first cell. - cell_at_file_start = True - cursor.movePosition(QTextCursor.Start) - - try: - footer = next(document_cells( - block, forward=True, - cell_list=self.get_cell_list())) - cell_end_position = footer.block.position() - cell_at_file_end = False - cursor.setPosition(cell_end_position, QTextCursor.KeepAnchor) - except StopIteration: - # This cell has no next header, so it is the last cell. - cell_at_file_end = True - cursor.movePosition(QTextCursor.End, QTextCursor.KeepAnchor) - - cell_full_file = cell_at_file_start and cell_at_file_end - self.current_cell = (cursor, cell_full_file) - - return cursor, cell_full_file - - def go_to_next_cell(self): - """Go to the next cell of lines""" - cursor = self.textCursor() - block = cursor.block() - try: - footer = next(document_cells( - block, forward=True, - cell_list=self.get_cell_list())) - cursor.setPosition(footer.block.position()) - except StopIteration: - return - self.setTextCursor(cursor) - - def go_to_previous_cell(self): - """Go to the previous cell of lines""" - cursor = self.textCursor() - block = cursor.block() - if is_cell_header(block): - block = block.previous() - try: - header = next(document_cells( - block, forward=False, - cell_list=self.get_cell_list())) - cursor.setPosition(header.block.position()) - except StopIteration: - return - self.setTextCursor(cursor) - - def get_line_count(self): - """Return document total line number""" - return self.blockCount() - - def paintEvent(self, e): - """ - Override Qt method to restore text selection after text gets inserted - at the current position of the cursor. - - See spyder-ide/spyder#11089 for more info. - """ - if self._restore_selection_pos is not None: - self.__restore_selection(*self._restore_selection_pos) - self._restore_selection_pos = None - super(TextEditBaseWidget, self).paintEvent(e) - - def __save_selection(self): - """Save current cursor selection and return position bounds""" - cursor = self.textCursor() - return cursor.selectionStart(), cursor.selectionEnd() - - def __restore_selection(self, start_pos, end_pos): - """Restore cursor selection from position bounds""" - cursor = self.textCursor() - cursor.setPosition(start_pos) - cursor.setPosition(end_pos, QTextCursor.KeepAnchor) - self.setTextCursor(cursor) - - def __duplicate_line_or_selection(self, after_current_line=True): - """Duplicate current line or selected text""" - cursor = self.textCursor() - cursor.beginEditBlock() - cur_pos = cursor.position() - start_pos, end_pos = self.__save_selection() - end_pos_orig = end_pos - if to_text_string(cursor.selectedText()): - cursor.setPosition(end_pos) - # Check if end_pos is at the start of a block: if so, starting - # changes from the previous block - cursor.movePosition(QTextCursor.StartOfBlock, - QTextCursor.KeepAnchor) - if not to_text_string(cursor.selectedText()): - cursor.movePosition(QTextCursor.PreviousBlock) - end_pos = cursor.position() - - cursor.setPosition(start_pos) - cursor.movePosition(QTextCursor.StartOfBlock) - while cursor.position() <= end_pos: - cursor.movePosition(QTextCursor.EndOfBlock, QTextCursor.KeepAnchor) - if cursor.atEnd(): - cursor_temp = QTextCursor(cursor) - cursor_temp.clearSelection() - cursor_temp.insertText(self.get_line_separator()) - break - cursor.movePosition(QTextCursor.NextBlock, QTextCursor.KeepAnchor) - text = cursor.selectedText() - cursor.clearSelection() - - if not after_current_line: - # Moving cursor before current line/selected text - cursor.setPosition(start_pos) - cursor.movePosition(QTextCursor.StartOfBlock) - start_pos += len(text) - end_pos_orig += len(text) - cur_pos += len(text) - - # We save the end and start position of the selection, so that it - # can be restored within the paint event that is triggered by the - # text insertion. This is done to prevent a graphical glitch that - # occurs when text gets inserted at the current position of the cursor. - # See spyder-ide/spyder#11089 for more info. - if cur_pos == start_pos: - self._restore_selection_pos = (end_pos_orig, start_pos) - else: - self._restore_selection_pos = (start_pos, end_pos_orig) - cursor.insertText(text) - cursor.endEditBlock() - - def duplicate_line_down(self): - """ - Copy current line or selected text and paste the duplicated text - *after* the current line or selected text. - """ - self.__duplicate_line_or_selection(after_current_line=False) - - def duplicate_line_up(self): - """ - Copy current line or selected text and paste the duplicated text - *before* the current line or selected text. - """ - self.__duplicate_line_or_selection(after_current_line=True) - - def __move_line_or_selection(self, after_current_line=True): - """Move current line or selected text""" - cursor = self.textCursor() - cursor.beginEditBlock() - start_pos, end_pos = self.__save_selection() - last_line = False - - # ------ Select text - # Get selection start location - cursor.setPosition(start_pos) - cursor.movePosition(QTextCursor.StartOfBlock) - start_pos = cursor.position() - - # Get selection end location - cursor.setPosition(end_pos) - if not cursor.atBlockStart() or end_pos == start_pos: - cursor.movePosition(QTextCursor.EndOfBlock) - cursor.movePosition(QTextCursor.NextBlock) - end_pos = cursor.position() - - # Check if selection ends on the last line of the document - if cursor.atEnd(): - if not cursor.atBlockStart() or end_pos == start_pos: - last_line = True - - # ------ Stop if at document boundary - cursor.setPosition(start_pos) - if cursor.atStart() and not after_current_line: - # Stop if selection is already at top of the file while moving up - cursor.endEditBlock() - self.setTextCursor(cursor) - self.__restore_selection(start_pos, end_pos) - return - - cursor.setPosition(end_pos, QTextCursor.KeepAnchor) - if last_line and after_current_line: - # Stop if selection is already at end of the file while moving down - cursor.endEditBlock() - self.setTextCursor(cursor) - self.__restore_selection(start_pos, end_pos) - return - - # ------ Move text - sel_text = to_text_string(cursor.selectedText()) - cursor.removeSelectedText() - - if after_current_line: - # Shift selection down - text = to_text_string(cursor.block().text()) - sel_text = os.linesep + sel_text[0:-1] # Move linesep at the start - cursor.movePosition(QTextCursor.EndOfBlock) - start_pos += len(text)+1 - end_pos += len(text) - if not cursor.atEnd(): - end_pos += 1 - else: - # Shift selection up - if last_line: - # Remove the last linesep and add it to the selected text - cursor.deletePreviousChar() - sel_text = sel_text + os.linesep - cursor.movePosition(QTextCursor.StartOfBlock) - end_pos += 1 - else: - cursor.movePosition(QTextCursor.PreviousBlock) - text = to_text_string(cursor.block().text()) - start_pos -= len(text)+1 - end_pos -= len(text)+1 - - cursor.insertText(sel_text) - - cursor.endEditBlock() - self.setTextCursor(cursor) - self.__restore_selection(start_pos, end_pos) - - def move_line_up(self): - """Move up current line or selected text""" - self.__move_line_or_selection(after_current_line=False) - - def move_line_down(self): - """Move down current line or selected text""" - self.__move_line_or_selection(after_current_line=True) - - def go_to_new_line(self): - """Go to the end of the current line and create a new line""" - self.stdkey_end(False, False) - self.insert_text(self.get_line_separator()) - - def extend_selection_to_complete_lines(self): - """Extend current selection to complete lines""" - cursor = self.textCursor() - start_pos, end_pos = cursor.selectionStart(), cursor.selectionEnd() - cursor.setPosition(start_pos) - cursor.setPosition(end_pos, QTextCursor.KeepAnchor) - if cursor.atBlockStart(): - cursor.movePosition(QTextCursor.PreviousBlock, - QTextCursor.KeepAnchor) - cursor.movePosition(QTextCursor.EndOfBlock, - QTextCursor.KeepAnchor) - self.setTextCursor(cursor) - - def delete_line(self, cursor=None): - """Delete current line.""" - if cursor is None: - cursor = self.textCursor() - if self.has_selected_text(): - self.extend_selection_to_complete_lines() - start_pos, end_pos = cursor.selectionStart(), cursor.selectionEnd() - cursor.setPosition(start_pos) - else: - start_pos = end_pos = cursor.position() - cursor.beginEditBlock() - cursor.setPosition(start_pos) - cursor.movePosition(QTextCursor.StartOfBlock) - while cursor.position() <= end_pos: - cursor.movePosition(QTextCursor.EndOfBlock, QTextCursor.KeepAnchor) - if cursor.atEnd(): - break - cursor.movePosition(QTextCursor.NextBlock, QTextCursor.KeepAnchor) - cursor.removeSelectedText() - cursor.endEditBlock() - self.ensureCursorVisible() - - def set_selection(self, start, end): - cursor = self.textCursor() - cursor.setPosition(start) - cursor.setPosition(end, QTextCursor.KeepAnchor) - self.setTextCursor(cursor) - - def truncate_selection(self, position_from): - """Unselect read-only parts in shell, like prompt""" - position_from = self.get_position(position_from) - cursor = self.textCursor() - start, end = cursor.selectionStart(), cursor.selectionEnd() - if start < end: - start = max([position_from, start]) - else: - end = max([position_from, end]) - self.set_selection(start, end) - - def restrict_cursor_position(self, position_from, position_to): - """In shell, avoid editing text except between prompt and EOF""" - position_from = self.get_position(position_from) - position_to = self.get_position(position_to) - cursor = self.textCursor() - cursor_position = cursor.position() - if cursor_position < position_from or cursor_position > position_to: - self.set_cursor_position(position_to) - - # ------Code completion / Calltips - def select_completion_list(self): - """Completion list is active, Enter was just pressed""" - self.completion_widget.item_selected() - - def insert_completion(self, completion, completion_position): - """Insert a completion into the editor. - - completion_position is where the completion was generated. - - The replacement range is computed using the (LSP) completion's - textEdit field if it exists. Otherwise, we replace from the - start of the word under the cursor. - """ - if not completion: - return - - cursor = self.textCursor() - - has_selected_text = self.has_selected_text() - selection_start, selection_end = self.get_selection_start_end() - - if isinstance(completion, dict) and 'textEdit' in completion: - completion_range = completion['textEdit']['range'] - start = completion_range['start'] - end = completion_range['end'] - if isinstance(completion_range['start'], dict): - start_line, start_col = start['line'], start['character'] - start = self.get_position_line_number(start_line, start_col) - if isinstance(completion_range['start'], dict): - end_line, end_col = end['line'], end['character'] - end = self.get_position_line_number(end_line, end_col) - cursor.setPosition(start) - cursor.setPosition(end, QTextCursor.KeepAnchor) - text = to_text_string(completion['textEdit']['newText']) - else: - text = completion - if isinstance(completion, dict): - text = completion['insertText'] - text = to_text_string(text) - - # Get word on the left of the cursor. - result = self.get_current_word_and_position(completion=True) - if result is not None: - current_text, start_position = result - end_position = start_position + len(current_text) - # Check if the completion position is in the expected range - if not start_position <= completion_position <= end_position: - return - cursor.setPosition(start_position) - # Remove the word under the cursor - cursor.setPosition(end_position, - QTextCursor.KeepAnchor) - else: - # Check if we are in the correct position - if cursor.position() != completion_position: - return - - if has_selected_text: - self.sig_will_remove_selection.emit(selection_start, selection_end) - - cursor.removeSelectedText() - self.setTextCursor(cursor) - - # Add text - if self.objectName() == 'console': - # Handle completions for the internal console - self.insert_text(text) - else: - self.sig_insert_completion.emit(text) - - def is_completion_widget_visible(self): - """Return True is completion list widget is visible""" - try: - return self.completion_widget.isVisible() - except RuntimeError: - # This is to avoid a RuntimeError exception when the widget is - # already been deleted. See spyder-ide/spyder#13248. - return False - - def hide_completion_widget(self, focus_to_parent=True): - """Hide completion widget and tooltip.""" - self.completion_widget.hide(focus_to_parent=focus_to_parent) - QToolTip.hideText() - - # ------Standard keys - def stdkey_clear(self): - if not self.has_selected_text(): - self.moveCursor(QTextCursor.NextCharacter, QTextCursor.KeepAnchor) - self.remove_selected_text() - - def stdkey_backspace(self): - if not self.has_selected_text(): - self.moveCursor(QTextCursor.PreviousCharacter, - QTextCursor.KeepAnchor) - self.remove_selected_text() - - def __get_move_mode(self, shift): - return QTextCursor.KeepAnchor if shift else QTextCursor.MoveAnchor - - def stdkey_up(self, shift): - self.moveCursor(QTextCursor.Up, self.__get_move_mode(shift)) - - def stdkey_down(self, shift): - self.moveCursor(QTextCursor.Down, self.__get_move_mode(shift)) - - def stdkey_tab(self): - self.insert_text(self.indent_chars) - - def stdkey_home(self, shift, ctrl, prompt_pos=None): - """Smart HOME feature: cursor is first moved at - indentation position, then at the start of the line""" - move_mode = self.__get_move_mode(shift) - if ctrl: - self.moveCursor(QTextCursor.Start, move_mode) - else: - cursor = self.textCursor() - if prompt_pos is None: - start_position = self.get_position('sol') - else: - start_position = self.get_position(prompt_pos) - text = self.get_text(start_position, 'eol') - indent_pos = start_position+len(text)-len(text.lstrip()) - if cursor.position() != indent_pos: - cursor.setPosition(indent_pos, move_mode) - else: - cursor.setPosition(start_position, move_mode) - self.setTextCursor(cursor) - - def stdkey_end(self, shift, ctrl): - move_mode = self.__get_move_mode(shift) - if ctrl: - self.moveCursor(QTextCursor.End, move_mode) - else: - self.moveCursor(QTextCursor.EndOfBlock, move_mode) - - # ----Qt Events - def mousePressEvent(self, event): - """Reimplement Qt method""" - - # mouse buttons for forward and backward navigation - if event.button() == Qt.XButton1: - self.sig_prev_cursor.emit() - elif event.button() == Qt.XButton2: - self.sig_next_cursor.emit() - - if sys.platform.startswith('linux') and event.button() == Qt.MidButton: - self.calltip_widget.hide() - self.setFocus() - event = QMouseEvent(QEvent.MouseButtonPress, event.pos(), - Qt.LeftButton, Qt.LeftButton, Qt.NoModifier) - QPlainTextEdit.mousePressEvent(self, event) - QPlainTextEdit.mouseReleaseEvent(self, event) - # Send selection text to clipboard to be able to use - # the paste method and avoid the strange spyder-ide/spyder#1445. - # NOTE: This issue seems a focusing problem but it - # seems really hard to track - mode_clip = QClipboard.Clipboard - mode_sel = QClipboard.Selection - text_clip = QApplication.clipboard().text(mode=mode_clip) - text_sel = QApplication.clipboard().text(mode=mode_sel) - QApplication.clipboard().setText(text_sel, mode=mode_clip) - self.paste() - QApplication.clipboard().setText(text_clip, mode=mode_clip) - else: - self.calltip_widget.hide() - QPlainTextEdit.mousePressEvent(self, event) - - def focusInEvent(self, event): - """Reimplemented to handle focus""" - self.focus_changed.emit() - self.focus_in.emit() - QPlainTextEdit.focusInEvent(self, event) - - def focusOutEvent(self, event): - """Reimplemented to handle focus""" - self.focus_changed.emit() - QPlainTextEdit.focusOutEvent(self, event) - - def wheelEvent(self, event): - """Reimplemented to emit zoom in/out signals when Ctrl is pressed""" - # This feature is disabled on MacOS, see spyder-ide/spyder#1510. - if sys.platform != 'darwin': - if event.modifiers() & Qt.ControlModifier: - if hasattr(event, 'angleDelta'): - if event.angleDelta().y() < 0: - self.zoom_out.emit() - elif event.angleDelta().y() > 0: - self.zoom_in.emit() - elif hasattr(event, 'delta'): - if event.delta() < 0: - self.zoom_out.emit() - elif event.delta() > 0: - self.zoom_in.emit() - return - - QPlainTextEdit.wheelEvent(self, event) - - # Needed to prevent stealing focus when scrolling. - # If the current widget with focus is the CompletionWidget, it means - # it's being displayed in the editor, so we need to hide it and give - # focus back to the editor. If not, we need to leave the focus in - # the widget that currently has it. - # See spyder-ide/spyder#11502 - current_widget = QApplication.focusWidget() - if isinstance(current_widget, CompletionWidget): - self.hide_completion_widget(focus_to_parent=True) - else: - self.hide_completion_widget(focus_to_parent=False) - - def position_widget_at_cursor(self, widget): - # Retrieve current screen height - desktop = QApplication.desktop() - srect = desktop.availableGeometry(desktop.screenNumber(widget)) - - left, top, right, bottom = (srect.left(), srect.top(), - srect.right(), srect.bottom()) - ancestor = widget.parent() - if ancestor: - left = max(left, ancestor.x()) - top = max(top, ancestor.y()) - right = min(right, ancestor.x() + ancestor.width()) - bottom = min(bottom, ancestor.y() + ancestor.height()) - - point = self.cursorRect().bottomRight() - point = self.calculate_real_position(point) - point = self.mapToGlobal(point) - # Move to left of cursor if not enough space on right - widget_right = point.x() + widget.width() - if widget_right > right: - point.setX(point.x() - widget.width()) - # Push to right if not enough space on left - if point.x() < left: - point.setX(left) - - # Moving widget above if there is not enough space below - widget_bottom = point.y() + widget.height() - x_position = point.x() - if widget_bottom > bottom: - point = self.cursorRect().topRight() - point = self.mapToGlobal(point) - point.setX(x_position) - point.setY(point.y() - widget.height()) - - if ancestor is not None: - # Useful only if we set parent to 'ancestor' in __init__ - point = ancestor.mapFromGlobal(point) - - widget.move(point) - - def calculate_real_position(self, point): - return point +# -*- coding: utf-8 -*- +# +# Copyright © Spyder Project Contributors +# Licensed under the terms of the MIT License +# (see spyder/__init__.py for details) + +"""QPlainTextEdit base class""" + +# pylint: disable=C0103 +# pylint: disable=R0903 +# pylint: disable=R0911 +# pylint: disable=R0201 + +# Standard library imports +import os +import sys + +# Third party imports +from qtpy.compat import to_qvariant +from qtpy.QtCore import QEvent, QPoint, Qt, Signal, Slot +from qtpy.QtGui import (QClipboard, QColor, QMouseEvent, QTextFormat, + QTextOption, QTextCursor) +from qtpy.QtWidgets import QApplication, QMainWindow, QPlainTextEdit, QToolTip + +# Local imports +from spyder.config.gui import get_font +from spyder.config.manager import CONF +from spyder.py3compat import PY3, to_text_string +from spyder.widgets.calltip import CallTipWidget, ToolTipWidget +from spyder.widgets.mixins import BaseEditMixin +from spyder.plugins.editor.api.decoration import TextDecoration, DRAW_ORDERS +from spyder.plugins.editor.utils.decoration import TextDecorationsManager +from spyder.plugins.editor.widgets.completion import CompletionWidget +from spyder.plugins.outlineexplorer.api import is_cell_header, document_cells +from spyder.utils.palette import SpyderPalette + +class TextEditBaseWidget(QPlainTextEdit, BaseEditMixin): + """Text edit base widget""" + BRACE_MATCHING_SCOPE = ('sof', 'eof') + focus_in = Signal() + zoom_in = Signal() + zoom_out = Signal() + zoom_reset = Signal() + focus_changed = Signal() + sig_insert_completion = Signal(str) + sig_eol_chars_changed = Signal(str) + sig_prev_cursor = Signal() + sig_next_cursor = Signal() + + def __init__(self, parent=None): + QPlainTextEdit.__init__(self, parent) + BaseEditMixin.__init__(self) + + self.has_cell_separators = False + self.setAttribute(Qt.WA_DeleteOnClose) + + self._restore_selection_pos = None + + # Trailing newlines/spaces trimming + self.remove_trailing_spaces = False + self.remove_trailing_newlines = False + + # Add a new line when saving + self.add_newline = False + + # Code snippets + self.code_snippets = True + + self.cursorPositionChanged.connect(self.cursor_position_changed) + + self.indent_chars = " "*4 + self.tab_stop_width_spaces = 4 + + # Code completion / calltips + if parent is not None: + mainwin = parent + while not isinstance(mainwin, QMainWindow): + mainwin = mainwin.parent() + if mainwin is None: + break + if mainwin is not None: + parent = mainwin + + self.completion_widget = CompletionWidget(self, parent) + self.codecompletion_auto = False + self.setup_completion() + + self.calltip_widget = CallTipWidget(self, hide_timer_on=False) + self.tooltip_widget = ToolTipWidget(self, as_tooltip=True) + + self.highlight_current_cell_enabled = False + + # The color values may be overridden by the syntax highlighter + # Highlight current line color + self.currentline_color = QColor( + SpyderPalette.COLOR_ERROR_2).lighter(190) + self.currentcell_color = QColor( + SpyderPalette.COLOR_ERROR_2).lighter(194) + + # Brace matching + self.bracepos = None + self.matched_p_color = QColor(SpyderPalette.COLOR_SUCCESS_1) + self.unmatched_p_color = QColor(SpyderPalette.COLOR_ERROR_2) + + self.decorations = TextDecorationsManager(self) + + # Save current cell. This is invalidated as soon as the text changes. + # Useful to avoid recomputing while scrolling. + self.current_cell = None + + def reset_current_cell(): + self.current_cell = None + self.highlight_current_cell() + + self.textChanged.connect(reset_current_cell) + + # Cache + self._current_cell_cursor = None + self._current_line_block = None + + def setup_completion(self): + size = CONF.get('main', 'completion/size') + font = get_font() + self.completion_widget.setup_appearance(size, font) + + def set_indent_chars(self, indent_chars): + self.indent_chars = indent_chars + + def set_tab_stop_width_spaces(self, tab_stop_width_spaces): + self.tab_stop_width_spaces = tab_stop_width_spaces + self.update_tab_stop_width_spaces() + + def set_remove_trailing_spaces(self, flag): + self.remove_trailing_spaces = flag + + def set_add_newline(self, add_newline): + self.add_newline = add_newline + + def set_remove_trailing_newlines(self, flag): + self.remove_trailing_newlines = flag + + def update_tab_stop_width_spaces(self): + self.setTabStopWidth(self.fontMetrics().width( + ' ' * self.tab_stop_width_spaces)) + + def set_palette(self, background, foreground): + """ + Set text editor palette colors: + background color and caret (text cursor) color + """ + # Because QtStylsheet overrides QPalette and because some style do not + # use the palette for all drawing (e.g. macOS styles), the background + # and foreground color of each TextEditBaseWidget instance must be set + # with a stylesheet extended with an ID Selector. + # Fixes spyder-ide/spyder#2028, spyder-ide/spyder#8069 and + # spyder-ide/spyder#9248. + if not self.objectName(): + self.setObjectName(self.__class__.__name__ + str(id(self))) + style = "QPlainTextEdit#%s {background: %s; color: %s;}" % \ + (self.objectName(), background.name(), foreground.name()) + self.setStyleSheet(style) + + # ---- Extra selections + def get_extra_selections(self, key): + """Return editor extra selections. + + Args: + key (str) name of the extra selections group + + Returns: + list of sourcecode.api.TextDecoration. + """ + return self.decorations.get(key, []) + + def set_extra_selections(self, key, extra_selections): + """Set extra selections for a key. + + Also assign draw orders to leave current_cell and current_line + in the background (and avoid them to cover other decorations) + + NOTE: This will remove previous decorations added to the same key. + + Args: + key (str) name of the extra selections group. + extra_selections (list of sourcecode.api.TextDecoration). + """ + # use draw orders to highlight current_cell and current_line first + draw_order = DRAW_ORDERS.get(key) + if draw_order is None: + draw_order = DRAW_ORDERS.get('on_top') + + for selection in extra_selections: + selection.draw_order = draw_order + selection.kind = key + + self.decorations.add_key(key, extra_selections) + self.update() + + def clear_extra_selections(self, key): + """Remove decorations added through set_extra_selections. + + Args: + key (str) name of the extra selections group. + """ + self.decorations.remove_key(key) + self.update() + + def get_visible_block_numbers(self): + """Get the first and last visible block numbers.""" + first = self.firstVisibleBlock().blockNumber() + bottom_right = QPoint(self.viewport().width() - 1, + self.viewport().height() - 1) + last = self.cursorForPosition(bottom_right).blockNumber() + return (first, last) + + def get_buffer_block_numbers(self): + """ + Get the first and last block numbers of a region that covers + the visible one plus a buffer of half that region above and + below to make more fluid certain operations. + """ + first_visible, last_visible = self.get_visible_block_numbers() + buffer_height = round((last_visible - first_visible) / 2) + + first = first_visible - buffer_height + first = 0 if first < 0 else first + + last = last_visible + buffer_height + last = self.blockCount() if last > self.blockCount() else last + + return (first, last) + + # ------Highlight current line + def highlight_current_line(self): + """Highlight current line""" + cursor = self.textCursor() + block = cursor.block() + if self._current_line_block == block: + return + self._current_line_block = block + selection = TextDecoration(cursor) + selection.format.setProperty(QTextFormat.FullWidthSelection, + to_qvariant(True)) + selection.format.setBackground(self.currentline_color) + selection.cursor.clearSelection() + self.set_extra_selections('current_line', [selection]) + + def unhighlight_current_line(self): + """Unhighlight current line""" + self._current_line_block = None + self.clear_extra_selections('current_line') + + # ------Highlight current cell + def highlight_current_cell(self): + """Highlight current cell""" + if (not self.has_cell_separators or + not self.highlight_current_cell_enabled): + self._current_cell_cursor = None + return + cursor, whole_file_selected = self.select_current_cell() + + def same_selection(c1, c2): + if c1 is None or c2 is None: + return False + return ( + c1.selectionStart() == c2.selectionStart() and + c1.selectionEnd() == c2.selectionEnd() + ) + + if same_selection(self._current_cell_cursor, cursor): + # Already correct + return + self._current_cell_cursor = cursor + selection = TextDecoration(cursor) + selection.format.setProperty(QTextFormat.FullWidthSelection, + to_qvariant(True)) + selection.format.setBackground(self.currentcell_color) + + if whole_file_selected: + self.clear_extra_selections('current_cell') + else: + self.set_extra_selections('current_cell', [selection]) + + def unhighlight_current_cell(self): + """Unhighlight current cell""" + self._current_cell_cursor = None + self.clear_extra_selections('current_cell') + + def in_comment(self, cursor=None, position=None): + """Returns True if the given position is inside a comment. + + Trivial default implementation. To be overridden by subclass. + This function is used to define the default behaviour of + self.find_brace_match. + """ + return False + + def in_string(self, cursor=None, position=None): + """Returns True if the given position is inside a string. + + Trivial default implementation. To be overridden by subclass. + This function is used to define the default behaviour of + self.find_brace_match. + """ + return False + + def find_brace_match(self, position, brace, forward, + ignore_brace=None, stop=None): + """Returns position of matching brace. + + Parameters + ---------- + position : int + The position of the brace to be matched. + brace : {'[', ']', '(', ')', '{', '}'} + The brace character to be matched. + [ <-> ], ( <-> ), { <-> } + forward : boolean + Whether to search forwards or backwards for a match. + ignore_brace : callable taking int returning boolean, optional + Whether to ignore a brace (as function of position). + stop : callable taking int returning boolean, optional + Whether to stop the search early (as function of position). + + If both *ignore_brace* and *stop* are None, then brace matching + is handled differently depending on whether *position* is + inside a string, comment or regular code. If in regular code, + then any braces inside strings and comments are ignored. If in a + string/comment, then only braces in the same string/comment are + considered potential matches. The functions self.in_comment and + self.in_string are used to determine string/comment/code status + of characters in this case. + + If exactly one of *ignore_brace* and *stop* is None, then it is + replaced by a function returning False for every position. I.e.: + lambda pos: False + + Returns + ------- + The position of the matching brace. If no matching brace + exists, then None is returned. + """ + + if ignore_brace is None and stop is None: + if self.in_string(position=position): + # Only search inside the current string + def stop(pos): + return not self.in_string(position=pos) + elif self.in_comment(position=position): + # Only search inside the current comment + def stop(pos): + return not self.in_comment(position=pos) + else: + # Ignore braces inside strings and comments + def ignore_brace(pos): + return (self.in_string(position=pos) or + self.in_comment(position=pos)) + + # Deal with search range and direction + start_pos, end_pos = self.BRACE_MATCHING_SCOPE + if forward: + closing_brace = {'(': ')', '[': ']', '{': '}'}[brace] + text = self.get_text(position, end_pos, remove_newlines=False) + else: + # Handle backwards search with the same code as forwards + # by reversing the string to be searched. + closing_brace = {')': '(', ']': '[', '}': '{'}[brace] + text = self.get_text(start_pos, position+1, remove_newlines=False) + text = text[-1::-1] # reverse + + def ind2pos(index): + """Computes editor position from search index.""" + return (position + index) if forward else (position - index) + + # Search starts at the first position after the given one + # (which is assumed to contain a brace). + i_start_close = 1 + i_start_open = 1 + while True: + i_close = text.find(closing_brace, i_start_close) + i_start_close = i_close+1 # next potential start + if i_close == -1: + return # no matching brace exists + elif ignore_brace is None or not ignore_brace(ind2pos(i_close)): + while True: + i_open = text.find(brace, i_start_open, i_close) + i_start_open = i_open+1 # next potential start + if i_open == -1: + # found matching brace, but should we have + # stopped before this point? + if stop is not None: + # There's room for optimization here... + for i in range(1, i_close+1): + if stop(ind2pos(i)): + return + return ind2pos(i_close) + elif (ignore_brace is None or + not ignore_brace(ind2pos(i_open))): + break # must find new closing brace + + def __highlight(self, positions, color=None, cancel=False): + if cancel: + self.clear_extra_selections('brace_matching') + return + extra_selections = [] + for position in positions: + if position > self.get_position('eof'): + return + selection = TextDecoration(self.textCursor()) + selection.format.setBackground(color) + selection.cursor.clearSelection() + selection.cursor.setPosition(position) + selection.cursor.movePosition(QTextCursor.NextCharacter, + QTextCursor.KeepAnchor) + extra_selections.append(selection) + self.set_extra_selections('brace_matching', extra_selections) + + def cursor_position_changed(self): + """Handle brace matching.""" + # Clear last brace highlight (if any) + if self.bracepos is not None: + self.__highlight(self.bracepos, cancel=True) + self.bracepos = None + + # Get the current cursor position, check if it is at a brace, + # and, if so, determine the direction in which to search for able + # matching brace. + cursor = self.textCursor() + if cursor.position() == 0: + return + cursor.movePosition(QTextCursor.PreviousCharacter, + QTextCursor.KeepAnchor) + text = to_text_string(cursor.selectedText()) + if text in (')', ']', '}'): + forward = False + elif text in ('(', '[', '{'): + forward = True + else: + return + + pos1 = cursor.position() + pos2 = self.find_brace_match(pos1, text, forward=forward) + + # Set a new brace highlight + if pos2 is not None: + self.bracepos = (pos1, pos2) + self.__highlight(self.bracepos, color=self.matched_p_color) + else: + self.bracepos = (pos1,) + self.__highlight(self.bracepos, color=self.unmatched_p_color) + + # -----Widget setup and options + def set_wrap_mode(self, mode=None): + """ + Set wrap mode + Valid *mode* values: None, 'word', 'character' + """ + if mode == 'word': + wrap_mode = QTextOption.WrapAtWordBoundaryOrAnywhere + elif mode == 'character': + wrap_mode = QTextOption.WrapAnywhere + else: + wrap_mode = QTextOption.NoWrap + self.setWordWrapMode(wrap_mode) + + # ------Reimplementing Qt methods + @Slot() + def copy(self): + """ + Reimplement Qt method + Copy text to clipboard with correct EOL chars + """ + if self.get_selected_text(): + QApplication.clipboard().setText(self.get_selected_text()) + + def toPlainText(self): + """ + Reimplement Qt method + Fix PyQt4 bug on Windows and Python 3 + """ + # Fix what appears to be a PyQt4 bug when getting file + # contents under Windows and PY3. This bug leads to + # corruptions when saving files with certain combinations + # of unicode chars on them (like the one attached on + # spyder-ide/spyder#1546). + if os.name == 'nt' and PY3: + text = self.get_text('sof', 'eof') + return text.replace('\u2028', '\n').replace('\u2029', '\n')\ + .replace('\u0085', '\n') + return super(TextEditBaseWidget, self).toPlainText() + + def keyPressEvent(self, event): + key = event.key() + ctrl = event.modifiers() & Qt.ControlModifier + meta = event.modifiers() & Qt.MetaModifier + # Use our own copy method for {Ctrl,Cmd}+C to avoid Qt + # copying text in HTML. See spyder-ide/spyder#2285. + if (ctrl or meta) and key == Qt.Key_C: + self.copy() + else: + super(TextEditBaseWidget, self).keyPressEvent(event) + + # ------Text: get, set, ... + def get_cell_list(self): + """Get all cells.""" + # Reimplemented in childrens + return [] + + def get_selection_as_executable_code(self, cursor=None): + """Return selected text as a processed text, + to be executable in a Python/IPython interpreter""" + ls = self.get_line_separator() + + _indent = lambda line: len(line)-len(line.lstrip()) + + line_from, line_to = self.get_selection_bounds(cursor) + text = self.get_selected_text(cursor) + if not text: + return + + lines = text.split(ls) + if len(lines) > 1: + # Multiline selection -> eventually fixing indentation + original_indent = _indent(self.get_text_line(line_from)) + text = (" "*(original_indent-_indent(lines[0])))+text + + # If there is a common indent to all lines, find it. + # Moving from bottom line to top line ensures that blank + # lines inherit the indent of the line *below* it, + # which is the desired behavior. + min_indent = 999 + current_indent = 0 + lines = text.split(ls) + for i in range(len(lines)-1, -1, -1): + line = lines[i] + if line.strip(): + current_indent = _indent(line) + min_indent = min(current_indent, min_indent) + else: + lines[i] = ' ' * current_indent + if min_indent: + lines = [line[min_indent:] for line in lines] + + # Remove any leading whitespace or comment lines + # since they confuse the reserved word detector that follows below + lines_removed = 0 + while lines: + first_line = lines[0].lstrip() + if first_line == '' or first_line[0] == '#': + lines_removed += 1 + lines.pop(0) + else: + break + + # Add an EOL character after the last line of code so that it gets + # evaluated automatically by the console and any quote characters + # are separated from the triple quotes of runcell + lines.append(ls) + + # Add removed lines back to have correct traceback line numbers + leading_lines_str = ls * lines_removed + + return leading_lines_str + ls.join(lines) + + def get_cell_as_executable_code(self, cursor=None): + """Return cell contents as executable code.""" + if cursor is None: + cursor = self.textCursor() + ls = self.get_line_separator() + cursor, __ = self.select_current_cell(cursor) + line_from, __ = self.get_selection_bounds(cursor) + # Get the block for the first cell line + start = cursor.selectionStart() + block = self.document().findBlock(start) + if not is_cell_header(block) and start > 0: + block = self.document().findBlock(start - 1) + # Get text + text = self.get_selection_as_executable_code(cursor) + if text is not None: + text = ls * line_from + text + return text, block + + def select_current_cell(self, cursor=None): + """ + Select cell under cursor in the visible portion of the file + cell = group of lines separated by CELL_SEPARATORS + returns + -the textCursor + -a boolean indicating if the entire file is selected + """ + if cursor is None: + cursor = self.textCursor() + + if self.current_cell: + current_cell, cell_full_file = self.current_cell + cell_start_pos = current_cell.selectionStart() + cell_end_position = current_cell.selectionEnd() + # Check if the saved current cell is still valid + if cell_start_pos <= cursor.position() < cell_end_position: + return current_cell, cell_full_file + else: + self.current_cell = None + + block = cursor.block() + try: + if is_cell_header(block): + header = block.userData().oedata + else: + header = next(document_cells( + block, forward=False, + cell_list=self.get_cell_list())) + cell_start_pos = header.block.position() + cell_at_file_start = False + cursor.setPosition(cell_start_pos) + except StopIteration: + # This cell has no header, so it is the first cell. + cell_at_file_start = True + cursor.movePosition(QTextCursor.Start) + + try: + footer = next(document_cells( + block, forward=True, + cell_list=self.get_cell_list())) + cell_end_position = footer.block.position() + cell_at_file_end = False + cursor.setPosition(cell_end_position, QTextCursor.KeepAnchor) + except StopIteration: + # This cell has no next header, so it is the last cell. + cell_at_file_end = True + cursor.movePosition(QTextCursor.End, QTextCursor.KeepAnchor) + + cell_full_file = cell_at_file_start and cell_at_file_end + self.current_cell = (cursor, cell_full_file) + + return cursor, cell_full_file + + def go_to_next_cell(self): + """Go to the next cell of lines""" + cursor = self.textCursor() + block = cursor.block() + try: + footer = next(document_cells( + block, forward=True, + cell_list=self.get_cell_list())) + cursor.setPosition(footer.block.position()) + except StopIteration: + return + self.setTextCursor(cursor) + + def go_to_previous_cell(self): + """Go to the previous cell of lines""" + cursor = self.textCursor() + block = cursor.block() + if is_cell_header(block): + block = block.previous() + try: + header = next(document_cells( + block, forward=False, + cell_list=self.get_cell_list())) + cursor.setPosition(header.block.position()) + except StopIteration: + return + self.setTextCursor(cursor) + + def get_line_count(self): + """Return document total line number""" + return self.blockCount() + + def paintEvent(self, e): + """ + Override Qt method to restore text selection after text gets inserted + at the current position of the cursor. + + See spyder-ide/spyder#11089 for more info. + """ + if self._restore_selection_pos is not None: + self.__restore_selection(*self._restore_selection_pos) + self._restore_selection_pos = None + super(TextEditBaseWidget, self).paintEvent(e) + + def __save_selection(self): + """Save current cursor selection and return position bounds""" + cursor = self.textCursor() + return cursor.selectionStart(), cursor.selectionEnd() + + def __restore_selection(self, start_pos, end_pos): + """Restore cursor selection from position bounds""" + cursor = self.textCursor() + cursor.setPosition(start_pos) + cursor.setPosition(end_pos, QTextCursor.KeepAnchor) + self.setTextCursor(cursor) + + def __duplicate_line_or_selection(self, after_current_line=True): + """Duplicate current line or selected text""" + cursor = self.textCursor() + cursor.beginEditBlock() + cur_pos = cursor.position() + start_pos, end_pos = self.__save_selection() + end_pos_orig = end_pos + if to_text_string(cursor.selectedText()): + cursor.setPosition(end_pos) + # Check if end_pos is at the start of a block: if so, starting + # changes from the previous block + cursor.movePosition(QTextCursor.StartOfBlock, + QTextCursor.KeepAnchor) + if not to_text_string(cursor.selectedText()): + cursor.movePosition(QTextCursor.PreviousBlock) + end_pos = cursor.position() + + cursor.setPosition(start_pos) + cursor.movePosition(QTextCursor.StartOfBlock) + while cursor.position() <= end_pos: + cursor.movePosition(QTextCursor.EndOfBlock, QTextCursor.KeepAnchor) + if cursor.atEnd(): + cursor_temp = QTextCursor(cursor) + cursor_temp.clearSelection() + cursor_temp.insertText(self.get_line_separator()) + break + cursor.movePosition(QTextCursor.NextBlock, QTextCursor.KeepAnchor) + text = cursor.selectedText() + cursor.clearSelection() + + if not after_current_line: + # Moving cursor before current line/selected text + cursor.setPosition(start_pos) + cursor.movePosition(QTextCursor.StartOfBlock) + start_pos += len(text) + end_pos_orig += len(text) + cur_pos += len(text) + + # We save the end and start position of the selection, so that it + # can be restored within the paint event that is triggered by the + # text insertion. This is done to prevent a graphical glitch that + # occurs when text gets inserted at the current position of the cursor. + # See spyder-ide/spyder#11089 for more info. + if cur_pos == start_pos: + self._restore_selection_pos = (end_pos_orig, start_pos) + else: + self._restore_selection_pos = (start_pos, end_pos_orig) + cursor.insertText(text) + cursor.endEditBlock() + + def duplicate_line_down(self): + """ + Copy current line or selected text and paste the duplicated text + *after* the current line or selected text. + """ + self.__duplicate_line_or_selection(after_current_line=False) + + def duplicate_line_up(self): + """ + Copy current line or selected text and paste the duplicated text + *before* the current line or selected text. + """ + self.__duplicate_line_or_selection(after_current_line=True) + + def __move_line_or_selection(self, after_current_line=True): + """Move current line or selected text""" + cursor = self.textCursor() + cursor.beginEditBlock() + start_pos, end_pos = self.__save_selection() + last_line = False + + # ------ Select text + # Get selection start location + cursor.setPosition(start_pos) + cursor.movePosition(QTextCursor.StartOfBlock) + start_pos = cursor.position() + + # Get selection end location + cursor.setPosition(end_pos) + if not cursor.atBlockStart() or end_pos == start_pos: + cursor.movePosition(QTextCursor.EndOfBlock) + cursor.movePosition(QTextCursor.NextBlock) + end_pos = cursor.position() + + # Check if selection ends on the last line of the document + if cursor.atEnd(): + if not cursor.atBlockStart() or end_pos == start_pos: + last_line = True + + # ------ Stop if at document boundary + cursor.setPosition(start_pos) + if cursor.atStart() and not after_current_line: + # Stop if selection is already at top of the file while moving up + cursor.endEditBlock() + self.setTextCursor(cursor) + self.__restore_selection(start_pos, end_pos) + return + + cursor.setPosition(end_pos, QTextCursor.KeepAnchor) + if last_line and after_current_line: + # Stop if selection is already at end of the file while moving down + cursor.endEditBlock() + self.setTextCursor(cursor) + self.__restore_selection(start_pos, end_pos) + return + + # ------ Move text + sel_text = to_text_string(cursor.selectedText()) + cursor.removeSelectedText() + + if after_current_line: + # Shift selection down + text = to_text_string(cursor.block().text()) + sel_text = os.linesep + sel_text[0:-1] # Move linesep at the start + cursor.movePosition(QTextCursor.EndOfBlock) + start_pos += len(text)+1 + end_pos += len(text) + if not cursor.atEnd(): + end_pos += 1 + else: + # Shift selection up + if last_line: + # Remove the last linesep and add it to the selected text + cursor.deletePreviousChar() + sel_text = sel_text + os.linesep + cursor.movePosition(QTextCursor.StartOfBlock) + end_pos += 1 + else: + cursor.movePosition(QTextCursor.PreviousBlock) + text = to_text_string(cursor.block().text()) + start_pos -= len(text)+1 + end_pos -= len(text)+1 + + cursor.insertText(sel_text) + + cursor.endEditBlock() + self.setTextCursor(cursor) + self.__restore_selection(start_pos, end_pos) + + def move_line_up(self): + """Move up current line or selected text""" + self.__move_line_or_selection(after_current_line=False) + + def move_line_down(self): + """Move down current line or selected text""" + self.__move_line_or_selection(after_current_line=True) + + def go_to_new_line(self): + """Go to the end of the current line and create a new line""" + self.stdkey_end(False, False) + self.insert_text(self.get_line_separator()) + + def extend_selection_to_complete_lines(self): + """Extend current selection to complete lines""" + cursor = self.textCursor() + start_pos, end_pos = cursor.selectionStart(), cursor.selectionEnd() + cursor.setPosition(start_pos) + cursor.setPosition(end_pos, QTextCursor.KeepAnchor) + if cursor.atBlockStart(): + cursor.movePosition(QTextCursor.PreviousBlock, + QTextCursor.KeepAnchor) + cursor.movePosition(QTextCursor.EndOfBlock, + QTextCursor.KeepAnchor) + self.setTextCursor(cursor) + + def delete_line(self, cursor=None): + """Delete current line.""" + if cursor is None: + cursor = self.textCursor() + if self.has_selected_text(): + self.extend_selection_to_complete_lines() + start_pos, end_pos = cursor.selectionStart(), cursor.selectionEnd() + cursor.setPosition(start_pos) + else: + start_pos = end_pos = cursor.position() + cursor.beginEditBlock() + cursor.setPosition(start_pos) + cursor.movePosition(QTextCursor.StartOfBlock) + while cursor.position() <= end_pos: + cursor.movePosition(QTextCursor.EndOfBlock, QTextCursor.KeepAnchor) + if cursor.atEnd(): + break + cursor.movePosition(QTextCursor.NextBlock, QTextCursor.KeepAnchor) + cursor.removeSelectedText() + cursor.endEditBlock() + self.ensureCursorVisible() + + def set_selection(self, start, end): + cursor = self.textCursor() + cursor.setPosition(start) + cursor.setPosition(end, QTextCursor.KeepAnchor) + self.setTextCursor(cursor) + + def truncate_selection(self, position_from): + """Unselect read-only parts in shell, like prompt""" + position_from = self.get_position(position_from) + cursor = self.textCursor() + start, end = cursor.selectionStart(), cursor.selectionEnd() + if start < end: + start = max([position_from, start]) + else: + end = max([position_from, end]) + self.set_selection(start, end) + + def restrict_cursor_position(self, position_from, position_to): + """In shell, avoid editing text except between prompt and EOF""" + position_from = self.get_position(position_from) + position_to = self.get_position(position_to) + cursor = self.textCursor() + cursor_position = cursor.position() + if cursor_position < position_from or cursor_position > position_to: + self.set_cursor_position(position_to) + + # ------Code completion / Calltips + def select_completion_list(self): + """Completion list is active, Enter was just pressed""" + self.completion_widget.item_selected() + + def insert_completion(self, completion, completion_position): + """Insert a completion into the editor. + + completion_position is where the completion was generated. + + The replacement range is computed using the (LSP) completion's + textEdit field if it exists. Otherwise, we replace from the + start of the word under the cursor. + """ + if not completion: + return + + cursor = self.textCursor() + + has_selected_text = self.has_selected_text() + selection_start, selection_end = self.get_selection_start_end() + + if isinstance(completion, dict) and 'textEdit' in completion: + completion_range = completion['textEdit']['range'] + start = completion_range['start'] + end = completion_range['end'] + if isinstance(completion_range['start'], dict): + start_line, start_col = start['line'], start['character'] + start = self.get_position_line_number(start_line, start_col) + if isinstance(completion_range['start'], dict): + end_line, end_col = end['line'], end['character'] + end = self.get_position_line_number(end_line, end_col) + cursor.setPosition(start) + cursor.setPosition(end, QTextCursor.KeepAnchor) + text = to_text_string(completion['textEdit']['newText']) + else: + text = completion + if isinstance(completion, dict): + text = completion['insertText'] + text = to_text_string(text) + + # Get word on the left of the cursor. + result = self.get_current_word_and_position(completion=True) + if result is not None: + current_text, start_position = result + end_position = start_position + len(current_text) + # Check if the completion position is in the expected range + if not start_position <= completion_position <= end_position: + return + cursor.setPosition(start_position) + # Remove the word under the cursor + cursor.setPosition(end_position, + QTextCursor.KeepAnchor) + else: + # Check if we are in the correct position + if cursor.position() != completion_position: + return + + if has_selected_text: + self.sig_will_remove_selection.emit(selection_start, selection_end) + + cursor.removeSelectedText() + self.setTextCursor(cursor) + + # Add text + if self.objectName() == 'console': + # Handle completions for the internal console + self.insert_text(text) + else: + self.sig_insert_completion.emit(text) + + def is_completion_widget_visible(self): + """Return True is completion list widget is visible""" + try: + return self.completion_widget.isVisible() + except RuntimeError: + # This is to avoid a RuntimeError exception when the widget is + # already been deleted. See spyder-ide/spyder#13248. + return False + + def hide_completion_widget(self, focus_to_parent=True): + """Hide completion widget and tooltip.""" + self.completion_widget.hide(focus_to_parent=focus_to_parent) + QToolTip.hideText() + + # ------Standard keys + def stdkey_clear(self): + if not self.has_selected_text(): + self.moveCursor(QTextCursor.NextCharacter, QTextCursor.KeepAnchor) + self.remove_selected_text() + + def stdkey_backspace(self): + if not self.has_selected_text(): + self.moveCursor(QTextCursor.PreviousCharacter, + QTextCursor.KeepAnchor) + self.remove_selected_text() + + def __get_move_mode(self, shift): + return QTextCursor.KeepAnchor if shift else QTextCursor.MoveAnchor + + def stdkey_up(self, shift): + self.moveCursor(QTextCursor.Up, self.__get_move_mode(shift)) + + def stdkey_down(self, shift): + self.moveCursor(QTextCursor.Down, self.__get_move_mode(shift)) + + def stdkey_tab(self): + self.insert_text(self.indent_chars) + + def stdkey_home(self, shift, ctrl, prompt_pos=None): + """Smart HOME feature: cursor is first moved at + indentation position, then at the start of the line""" + move_mode = self.__get_move_mode(shift) + if ctrl: + self.moveCursor(QTextCursor.Start, move_mode) + else: + cursor = self.textCursor() + if prompt_pos is None: + start_position = self.get_position('sol') + else: + start_position = self.get_position(prompt_pos) + text = self.get_text(start_position, 'eol') + indent_pos = start_position+len(text)-len(text.lstrip()) + if cursor.position() != indent_pos: + cursor.setPosition(indent_pos, move_mode) + else: + cursor.setPosition(start_position, move_mode) + self.setTextCursor(cursor) + + def stdkey_end(self, shift, ctrl): + move_mode = self.__get_move_mode(shift) + if ctrl: + self.moveCursor(QTextCursor.End, move_mode) + else: + self.moveCursor(QTextCursor.EndOfBlock, move_mode) + + # ----Qt Events + def mousePressEvent(self, event): + """Reimplement Qt method""" + + # mouse buttons for forward and backward navigation + if event.button() == Qt.XButton1: + self.sig_prev_cursor.emit() + elif event.button() == Qt.XButton2: + self.sig_next_cursor.emit() + + if sys.platform.startswith('linux') and event.button() == Qt.MidButton: + self.calltip_widget.hide() + self.setFocus() + event = QMouseEvent(QEvent.MouseButtonPress, event.pos(), + Qt.LeftButton, Qt.LeftButton, Qt.NoModifier) + QPlainTextEdit.mousePressEvent(self, event) + QPlainTextEdit.mouseReleaseEvent(self, event) + # Send selection text to clipboard to be able to use + # the paste method and avoid the strange spyder-ide/spyder#1445. + # NOTE: This issue seems a focusing problem but it + # seems really hard to track + mode_clip = QClipboard.Clipboard + mode_sel = QClipboard.Selection + text_clip = QApplication.clipboard().text(mode=mode_clip) + text_sel = QApplication.clipboard().text(mode=mode_sel) + QApplication.clipboard().setText(text_sel, mode=mode_clip) + self.paste() + QApplication.clipboard().setText(text_clip, mode=mode_clip) + else: + self.calltip_widget.hide() + QPlainTextEdit.mousePressEvent(self, event) + + def focusInEvent(self, event): + """Reimplemented to handle focus""" + self.focus_changed.emit() + self.focus_in.emit() + QPlainTextEdit.focusInEvent(self, event) + + def focusOutEvent(self, event): + """Reimplemented to handle focus""" + self.focus_changed.emit() + QPlainTextEdit.focusOutEvent(self, event) + + def wheelEvent(self, event): + """Reimplemented to emit zoom in/out signals when Ctrl is pressed""" + # This feature is disabled on MacOS, see spyder-ide/spyder#1510. + if sys.platform != 'darwin': + if event.modifiers() & Qt.ControlModifier: + if hasattr(event, 'angleDelta'): + if event.angleDelta().y() < 0: + self.zoom_out.emit() + elif event.angleDelta().y() > 0: + self.zoom_in.emit() + elif hasattr(event, 'delta'): + if event.delta() < 0: + self.zoom_out.emit() + elif event.delta() > 0: + self.zoom_in.emit() + return + + QPlainTextEdit.wheelEvent(self, event) + + # Needed to prevent stealing focus when scrolling. + # If the current widget with focus is the CompletionWidget, it means + # it's being displayed in the editor, so we need to hide it and give + # focus back to the editor. If not, we need to leave the focus in + # the widget that currently has it. + # See spyder-ide/spyder#11502 + current_widget = QApplication.focusWidget() + if isinstance(current_widget, CompletionWidget): + self.hide_completion_widget(focus_to_parent=True) + else: + self.hide_completion_widget(focus_to_parent=False) + + def position_widget_at_cursor(self, widget): + # Retrieve current screen height + desktop = QApplication.desktop() + srect = desktop.availableGeometry(desktop.screenNumber(widget)) + + left, top, right, bottom = (srect.left(), srect.top(), + srect.right(), srect.bottom()) + ancestor = widget.parent() + if ancestor: + left = max(left, ancestor.x()) + top = max(top, ancestor.y()) + right = min(right, ancestor.x() + ancestor.width()) + bottom = min(bottom, ancestor.y() + ancestor.height()) + + point = self.cursorRect().bottomRight() + point = self.calculate_real_position(point) + point = self.mapToGlobal(point) + # Move to left of cursor if not enough space on right + widget_right = point.x() + widget.width() + if widget_right > right: + point.setX(point.x() - widget.width()) + # Push to right if not enough space on left + if point.x() < left: + point.setX(left) + + # Moving widget above if there is not enough space below + widget_bottom = point.y() + widget.height() + x_position = point.x() + if widget_bottom > bottom: + point = self.cursorRect().topRight() + point = self.mapToGlobal(point) + point.setX(x_position) + point.setY(point.y() - widget.height()) + + if ancestor is not None: + # Useful only if we set parent to 'ancestor' in __init__ + point = ancestor.mapFromGlobal(point) + + widget.move(point) + + def calculate_real_position(self, point): + return point diff --git a/spyder/plugins/editor/widgets/codeeditor.py b/spyder/plugins/editor/widgets/codeeditor.py index 18e2f6eb0ae..7094309a60e 100644 --- a/spyder/plugins/editor/widgets/codeeditor.py +++ b/spyder/plugins/editor/widgets/codeeditor.py @@ -1,5595 +1,5595 @@ -# -*- coding: utf-8 -*- -# -# Copyright © Spyder Project Contributors -# Licensed under the terms of the MIT License -# (see spyder/__init__.py for details) - -""" -Editor widget based on QtGui.QPlainTextEdit -""" - -# TODO: Try to separate this module from spyder to create a self -# consistent editor module (Qt source code and shell widgets library) - -# pylint: disable=C0103 -# pylint: disable=R0903 -# pylint: disable=R0911 -# pylint: disable=R0201 - -# Standard library imports -from unicodedata import category -import logging -import functools -import os -import os.path as osp -import re -import sre_constants -import sys -import textwrap -from pkg_resources import parse_version - -# Third party imports -from diff_match_patch import diff_match_patch -from IPython.core.inputtransformer2 import TransformerManager -from qtpy import QT_VERSION -from qtpy.compat import to_qvariant -from qtpy.QtCore import (QEvent, QEventLoop, QRegExp, Qt, QTimer, QThread, - QUrl, Signal, Slot) -from qtpy.QtGui import (QColor, QCursor, QFont, QKeySequence, QPaintEvent, - QPainter, QMouseEvent, QTextCursor, QDesktopServices, - QKeyEvent, QTextDocument, QTextFormat, QTextOption, - QTextCharFormat, QTextLayout) -from qtpy.QtWidgets import (QApplication, QMenu, QMessageBox, QSplitter, - QScrollBar) -from spyder_kernels.utils.dochelpers import getobj -from three_merge import merge - - -# Local imports -from spyder.api.panel import Panel -from spyder.config.base import _, get_debug_level, running_under_pytest -from spyder.config.manager import CONF -from spyder.plugins.editor.api.decoration import TextDecoration -from spyder.plugins.editor.extensions import (CloseBracketsExtension, - CloseQuotesExtension, - DocstringWriterExtension, - QMenuOnlyForEnter, - EditorExtensionsManager, - SnippetsExtension) -from spyder.plugins.completion.providers.kite.widgets import KiteCallToAction -from spyder.plugins.completion.api import (CompletionRequestTypes, - TextDocumentSyncKind, - DiagnosticSeverity) -from spyder.plugins.editor.panels import (ClassFunctionDropdown, - DebuggerPanel, EdgeLine, - FoldingPanel, IndentationGuide, - LineNumberArea, PanelsManager, - ScrollFlagArea) -from spyder.plugins.editor.utils.editor import (TextHelper, BlockUserData, - get_file_language) -from spyder.plugins.editor.utils.debugger import DebuggerManager -from spyder.plugins.editor.utils.kill_ring import QtKillRing -from spyder.plugins.editor.utils.languages import ALL_LANGUAGES, CELL_LANGUAGES -from spyder.plugins.editor.panels.utils import ( - merge_folding, collect_folding_regions) -from spyder.plugins.completion.decorators import ( - request, handles, class_register) -from spyder.plugins.editor.widgets.codeeditor_widgets import GoToLineDialog -from spyder.plugins.editor.widgets.base import TextEditBaseWidget -from spyder.plugins.outlineexplorer.api import (OutlineExplorerData as OED, - is_cell_header) -from spyder.py3compat import PY2, to_text_string, is_string, is_text_string -from spyder.utils import encoding, sourcecode -from spyder.utils.clipboard_helper import CLIPBOARD_HELPER -from spyder.utils.icon_manager import ima -from spyder.utils import syntaxhighlighters as sh -from spyder.utils.palette import SpyderPalette, QStylePalette -from spyder.utils.qthelpers import (add_actions, create_action, file_uri, - mimedata2url, start_file) -from spyder.utils.vcs import get_git_remotes, remote_to_url -from spyder.utils.qstringhelpers import qstring_length - - -try: - import nbformat as nbformat - from nbconvert import PythonExporter as nbexporter -except Exception: - nbformat = None # analysis:ignore - -logger = logging.getLogger(__name__) - - -# Regexp to detect noqa inline comments. -NOQA_INLINE_REGEXP = re.compile(r"#?noqa", re.IGNORECASE) - - -def schedule_request(req=None, method=None, requires_response=True): - """Call function req and then emit its results to the completion server.""" - if req is None: - return functools.partial(schedule_request, method=method, - requires_response=requires_response) - - @functools.wraps(req) - def wrapper(self, *args, **kwargs): - params = req(self, *args, **kwargs) - if params is not None and self.completions_available: - self._pending_server_requests.append( - (method, params, requires_response)) - self._server_requests_timer.start() - return wrapper - - -@class_register -class CodeEditor(TextEditBaseWidget): - """Source Code Editor Widget based exclusively on Qt""" - - LANGUAGES = { - 'Python': (sh.PythonSH, '#'), - 'IPython': (sh.IPythonSH, '#'), - 'Cython': (sh.CythonSH, '#'), - 'Fortran77': (sh.Fortran77SH, 'c'), - 'Fortran': (sh.FortranSH, '!'), - 'Idl': (sh.IdlSH, ';'), - 'Diff': (sh.DiffSH, ''), - 'GetText': (sh.GetTextSH, '#'), - 'Nsis': (sh.NsisSH, '#'), - 'Html': (sh.HtmlSH, ''), - 'Yaml': (sh.YamlSH, '#'), - 'Cpp': (sh.CppSH, '//'), - 'OpenCL': (sh.OpenCLSH, '//'), - 'Enaml': (sh.EnamlSH, '#'), - 'Markdown': (sh.MarkdownSH, '#'), - # Every other language - 'None': (sh.TextSH, ''), - } - - TAB_ALWAYS_INDENTS = ( - 'py', 'pyw', 'python', 'ipy', 'c', 'cpp', 'cl', 'h', 'pyt', 'pyi' - ) - - # Timeout to update decorations (through a QTimer) when a position - # changed is detected in the vertical scrollbar or when releasing - # the up/down arrow keys. - UPDATE_DECORATIONS_TIMEOUT = 500 # milliseconds - - # Timeouts (in milliseconds) to sychronize symbols and folding after - # linting results arrive, according to the number of lines in the file. - SYNC_SYMBOLS_AND_FOLDING_TIMEOUTS = { - # Lines: Timeout - 500: 350, - 1500: 800, - 2500: 1200, - 6500: 1800 - } - - # Custom signal to be emitted upon completion of the editor's paintEvent - painted = Signal(QPaintEvent) - - # To have these attrs when early viewportEvent's are triggered - edge_line = None - indent_guides = None - - sig_breakpoints_changed = Signal() - sig_repaint_breakpoints = Signal() - sig_debug_stop = Signal((int,), ()) - sig_debug_start = Signal() - sig_breakpoints_saved = Signal() - sig_filename_changed = Signal(str) - sig_bookmarks_changed = Signal() - go_to_definition = Signal(str, int, int) - sig_show_object_info = Signal(int) - sig_run_selection = Signal() - sig_run_to_line = Signal() - sig_run_from_line = Signal() - sig_run_cell_and_advance = Signal() - sig_run_cell = Signal() - sig_re_run_last_cell = Signal() - sig_debug_cell = Signal() - sig_cursor_position_changed = Signal(int, int) - sig_new_file = Signal(str) - sig_refresh_formatting = Signal(bool) - - #: Signal emitted when the editor loses focus - sig_focus_changed = Signal() - - #: Signal emitted when a key is pressed - sig_key_pressed = Signal(QKeyEvent) - - #: Signal emitted when a key is released - sig_key_released = Signal(QKeyEvent) - - #: Signal emitted when the alt key is pressed and the left button of the - # mouse is clicked - sig_alt_left_mouse_pressed = Signal(QMouseEvent) - - #: Signal emitted when the alt key is pressed and the cursor moves over - # the editor - sig_alt_mouse_moved = Signal(QMouseEvent) - - #: Signal emitted when the cursor leaves the editor - sig_leave_out = Signal() - - #: Signal emitted when the flags need to be updated in the scrollflagarea - sig_flags_changed = Signal() - - #: Signal emitted when the syntax color theme of the editor. - sig_theme_colors_changed = Signal(dict) - - #: Signal emitted when a new text is set on the widget - new_text_set = Signal() - - # -- LSP signals - #: Signal emitted when an LSP request is sent to the LSP manager - sig_perform_completion_request = Signal(str, str, dict) - - #: Signal emitted when a response is received from the completion plugin - # For now it's only used on tests, but it could be used to track - # and profile completion diagnostics. - completions_response_signal = Signal(str, object) - - #: Signal to display object information on the Help plugin - sig_display_object_info = Signal(str, bool) - - #: Signal only used for tests - # TODO: Remove it! - sig_signature_invoked = Signal(dict) - - #: Signal emitted when processing code analysis warnings is finished - sig_process_code_analysis = Signal() - - # Used for testing. When the mouse moves with Ctrl/Cmd pressed and - # a URI is found, this signal is emitted - sig_uri_found = Signal(str) - - sig_file_uri_preprocessed = Signal(str) - """ - This signal is emitted when the go to uri for a file has been - preprocessed. - - Parameters - ---------- - fpath: str - The preprocessed file path. - """ - - # Signal with the info about the current completion item documentation - # str: object name - # str: object signature/documentation - # bool: force showing the info - sig_show_completion_object_info = Signal(str, str, bool) - - # Used to indicate if text was inserted into the editor - sig_text_was_inserted = Signal() - - # Used to indicate that text will be inserted into the editor - sig_will_insert_text = Signal(str) - - # Used to indicate that a text selection will be removed - sig_will_remove_selection = Signal(tuple, tuple) - - # Used to indicate that text will be pasted - sig_will_paste_text = Signal(str) - - # Used to indicate that an undo operation will take place - sig_undo = Signal() - - # Used to indicate that an undo operation will take place - sig_redo = Signal() - - # Used to start the status spinner in the editor - sig_start_operation_in_progress = Signal() - - # Used to start the status spinner in the editor - sig_stop_operation_in_progress = Signal() - - # Used to signal font change - sig_font_changed = Signal() - - def __init__(self, parent=None): - TextEditBaseWidget.__init__(self, parent) - - self.setFocusPolicy(Qt.StrongFocus) - - # Projects - self.current_project_path = None - - # Caret (text cursor) - self.setCursorWidth(CONF.get('main', 'cursor/width')) - - self.text_helper = TextHelper(self) - - self._panels = PanelsManager(self) - - # Mouse moving timer / Hover hints handling - # See: mouseMoveEvent - self.tooltip_widget.sig_help_requested.connect( - self.show_object_info) - self.tooltip_widget.sig_completion_help_requested.connect( - self.show_completion_object_info) - self._last_point = None - self._last_hover_word = None - self._last_hover_cursor = None - self._timer_mouse_moving = QTimer(self) - self._timer_mouse_moving.setInterval(350) - self._timer_mouse_moving.setSingleShot(True) - self._timer_mouse_moving.timeout.connect(self._handle_hover) - - # Typing keys / handling on the fly completions - # See: keyPressEvent - self._last_key_pressed_text = '' - self._last_pressed_key = None - self._timer_autocomplete = QTimer(self) - self._timer_autocomplete.setSingleShot(True) - self._timer_autocomplete.timeout.connect(self._handle_completions) - - # Handle completions hints - self._completions_hint_idle = False - self._timer_completions_hint = QTimer(self) - self._timer_completions_hint.setSingleShot(True) - self._timer_completions_hint.timeout.connect( - self._set_completions_hint_idle) - self.completion_widget.sig_completion_hint.connect( - self.show_hint_for_completion) - - # Request symbols and folding after a timeout. - # See: process_diagnostics - self._timer_sync_symbols_and_folding = QTimer(self) - self._timer_sync_symbols_and_folding.setSingleShot(True) - self._timer_sync_symbols_and_folding.timeout.connect( - self.sync_symbols_and_folding) - self.blockCountChanged.connect( - self.set_sync_symbols_and_folding_timeout) - - # Goto uri - self._last_hover_pattern_key = None - self._last_hover_pattern_text = None - - # 79-col edge line - self.edge_line = self.panels.register(EdgeLine(), - Panel.Position.FLOATING) - - # indent guides - self.indent_guides = self.panels.register(IndentationGuide(), - Panel.Position.FLOATING) - # Blanks enabled - self.blanks_enabled = False - - # Underline errors and warnings - self.underline_errors_enabled = False - - # Scrolling past the end of the document - self.scrollpastend_enabled = False - - self.background = QColor('white') - - # Folding - self.panels.register(FoldingPanel()) - - # Debugger panel (Breakpoints) - self.debugger = DebuggerManager(self) - self.panels.register(DebuggerPanel()) - # Update breakpoints if the number of lines in the file changes - self.blockCountChanged.connect(self.sig_breakpoints_changed) - - # Line number area management - self.linenumberarea = self.panels.register(LineNumberArea()) - - # Class and Method/Function Dropdowns - self.classfuncdropdown = self.panels.register( - ClassFunctionDropdown(), - Panel.Position.TOP, - ) - - # Colors to be defined in _apply_highlighter_color_scheme() - # Currentcell color and current line color are defined in base.py - self.occurrence_color = None - self.ctrl_click_color = None - self.sideareas_color = None - self.matched_p_color = None - self.unmatched_p_color = None - self.normal_color = None - self.comment_color = None - - # --- Syntax highlight entrypoint --- - # - # - if set, self.highlighter is responsible for - # - coloring raw text data inside editor on load - # - coloring text data when editor is cloned - # - updating document highlight on line edits - # - providing color palette (scheme) for the editor - # - providing data for Outliner - # - self.highlighter is not responsible for - # - background highlight for current line - # - background highlight for search / current line occurrences - - self.highlighter_class = sh.TextSH - self.highlighter = None - ccs = 'Spyder' - if ccs not in sh.COLOR_SCHEME_NAMES: - ccs = sh.COLOR_SCHEME_NAMES[0] - self.color_scheme = ccs - - self.highlight_current_line_enabled = False - - # Vertical scrollbar - # This is required to avoid a "RuntimeError: no access to protected - # functions or signals for objects not created from Python" in - # Linux Ubuntu. See spyder-ide/spyder#5215. - self.setVerticalScrollBar(QScrollBar()) - - # Highlights and flag colors - self.warning_color = SpyderPalette.COLOR_WARN_2 - self.error_color = SpyderPalette.COLOR_ERROR_1 - self.todo_color = SpyderPalette.GROUP_9 - self.breakpoint_color = SpyderPalette.ICON_3 - self.occurrence_color = QColor(SpyderPalette.GROUP_2).lighter(160) - self.found_results_color = QColor(SpyderPalette.COLOR_OCCURRENCE_4) - - # Scrollbar flag area - self.scrollflagarea = self.panels.register(ScrollFlagArea(), - Panel.Position.RIGHT) - self.panels.refresh() - - self.document_id = id(self) - - # Indicate occurrences of the selected word - self.cursorPositionChanged.connect(self.__cursor_position_changed) - self.__find_first_pos = None - - self.language = None - self.supported_language = False - self.supported_cell_language = False - self.comment_string = None - self._kill_ring = QtKillRing(self) - - # Block user data - self.blockCountChanged.connect(self.update_bookmarks) - - # Highlight using Pygments highlighter timer - # --------------------------------------------------------------------- - # For files that use the PygmentsSH we parse the full file inside - # the highlighter in order to generate the correct coloring. - self.timer_syntax_highlight = QTimer(self) - self.timer_syntax_highlight.setSingleShot(True) - self.timer_syntax_highlight.timeout.connect( - self.run_pygments_highlighter) - - # Mark occurrences timer - self.occurrence_highlighting = None - self.occurrence_timer = QTimer(self) - self.occurrence_timer.setSingleShot(True) - self.occurrence_timer.setInterval(1500) - self.occurrence_timer.timeout.connect(self.__mark_occurrences) - self.occurrences = [] - - # Update decorations - self.update_decorations_timer = QTimer(self) - self.update_decorations_timer.setSingleShot(True) - self.update_decorations_timer.setInterval( - self.UPDATE_DECORATIONS_TIMEOUT) - self.update_decorations_timer.timeout.connect( - self.update_decorations) - self.verticalScrollBar().valueChanged.connect( - lambda value: self.update_decorations_timer.start()) - - # LSP - self.textChanged.connect(self.schedule_document_did_change) - self._pending_server_requests = [] - self._server_requests_timer = QTimer(self) - self._server_requests_timer.setSingleShot(True) - self._server_requests_timer.setInterval(100) - self._server_requests_timer.timeout.connect( - self.process_server_requests) - - # Mark found results - self.textChanged.connect(self.__text_has_changed) - self.found_results = [] - - # Docstring - self.writer_docstring = DocstringWriterExtension(self) - - # Context menu - self.gotodef_action = None - self.setup_context_menu() - - # Tab key behavior - self.tab_indents = None - self.tab_mode = True # see CodeEditor.set_tab_mode - - # Intelligent backspace mode - self.intelligent_backspace = True - - # Automatic (on the fly) completions - self.automatic_completions = True - self.automatic_completions_after_chars = 3 - self.automatic_completions_after_ms = 300 - - # Code Folding - self.code_folding = True - self.update_folding_thread = QThread(None) - self.update_folding_thread.finished.connect(self.finish_code_folding) - - # Completions hint - self.completions_hint = True - self.completions_hint_after_ms = 500 - - self.close_parentheses_enabled = True - self.close_quotes_enabled = False - self.add_colons_enabled = True - self.auto_unindent_enabled = True - - # Autoformat on save - self.format_on_save = False - self.format_eventloop = QEventLoop(None) - self.format_timer = QTimer(self) - - # Mouse tracking - self.setMouseTracking(True) - self.__cursor_changed = False - self._mouse_left_button_pressed = False - self.ctrl_click_color = QColor(Qt.blue) - - self._bookmarks_blocks = {} - self.bookmarks = [] - - # Keyboard shortcuts - self.shortcuts = self.create_shortcuts() - - # Paint event - self.__visible_blocks = [] # Visible blocks, update with repaint - self.painted.connect(self._draw_editor_cell_divider) - - # Outline explorer - self.oe_proxy = None - - # Line stripping - self.last_change_position = None - self.last_position = None - self.last_auto_indent = None - self.skip_rstrip = False - self.strip_trailing_spaces_on_modify = True - - # Hover hints - self.hover_hints_enabled = None - - # Language Server - self.filename = None - self.completions_available = False - self.text_version = 0 - self.save_include_text = True - self.open_close_notifications = True - self.sync_mode = TextDocumentSyncKind.FULL - self.will_save_notify = False - self.will_save_until_notify = False - self.enable_hover = True - self.auto_completion_characters = [] - self.resolve_completions_enabled = False - self.signature_completion_characters = [] - self.go_to_definition_enabled = False - self.find_references_enabled = False - self.highlight_enabled = False - self.formatting_enabled = False - self.range_formatting_enabled = False - self.document_symbols_enabled = False - self.formatting_characters = [] - self.completion_args = None - self.folding_supported = False - self.is_cloned = False - self.operation_in_progress = False - self.formatting_in_progress = False - - # Diagnostics - self.update_diagnostics_thread = QThread(None) - self.update_diagnostics_thread.run = self.set_errors - self.update_diagnostics_thread.finished.connect( - self.finish_code_analysis) - self._diagnostics = [] - - # Editor Extensions - self.editor_extensions = EditorExtensionsManager(self) - self.editor_extensions.add(CloseQuotesExtension()) - self.editor_extensions.add(SnippetsExtension()) - self.editor_extensions.add(CloseBracketsExtension()) - - # Text diffs across versions - self.differ = diff_match_patch() - self.previous_text = '' - self.patch = [] - self.leading_whitespaces = {} - - # re-use parent of completion_widget (usually the main window) - completion_parent = self.completion_widget.parent() - self.kite_call_to_action = KiteCallToAction(self, completion_parent) - - # Some events should not be triggered during undo/redo - # such as line stripping - self.is_undoing = False - self.is_redoing = False - - # Timer to Avoid too many calls to rehighlight. - self._rehighlight_timer = QTimer(self) - self._rehighlight_timer.setSingleShot(True) - self._rehighlight_timer.setInterval(150) - - # --- Helper private methods - # ------------------------------------------------------------------------ - def process_server_requests(self): - """Process server requests.""" - # Check if document needs to be updated: - if self._document_server_needs_update: - self.document_did_change() - self._document_server_needs_update = False - for method, params, requires_response in self._pending_server_requests: - self.emit_request(method, params, requires_response) - self._pending_server_requests = [] - - # --- Hover/Hints - def _should_display_hover(self, point): - """Check if a hover hint should be displayed:""" - if not self._mouse_left_button_pressed: - return (self.hover_hints_enabled and point - and self.get_word_at(point)) - - def _handle_hover(self): - """Handle hover hint trigger after delay.""" - self._timer_mouse_moving.stop() - pos = self._last_point - - # These are textual characters but should not trigger a completion - # FIXME: update per language - ignore_chars = ['(', ')', '.'] - - if self._should_display_hover(pos): - key, pattern_text, cursor = self.get_pattern_at(pos) - text = self.get_word_at(pos) - if pattern_text: - ctrl_text = 'Cmd' if sys.platform == "darwin" else 'Ctrl' - if key in ['file']: - hint_text = ctrl_text + ' + ' + _('click to open file') - elif key in ['mail']: - hint_text = ctrl_text + ' + ' + _('click to send email') - elif key in ['url']: - hint_text = ctrl_text + ' + ' + _('click to open url') - else: - hint_text = ctrl_text + ' + ' + _('click to open') - - hint_text = ' {} '.format(hint_text) - - self.show_tooltip(text=hint_text, at_point=pos) - return - - cursor = self.cursorForPosition(pos) - cursor_offset = cursor.position() - line, col = cursor.blockNumber(), cursor.columnNumber() - self._last_point = pos - if text and self._last_hover_word != text: - if all(char not in text for char in ignore_chars): - self._last_hover_word = text - self.request_hover(line, col, cursor_offset) - else: - self.hide_tooltip() - elif not self.is_completion_widget_visible(): - self.hide_tooltip() - - def blockuserdata_list(self): - """Get the list of all user data in document.""" - block = self.document().firstBlock() - while block.isValid(): - data = block.userData() - if data: - yield data - block = block.next() - - def outlineexplorer_data_list(self): - """Get the list of all user data in document.""" - for data in self.blockuserdata_list(): - if data.oedata: - yield data.oedata - - # ---- Keyboard Shortcuts - - def create_cursor_callback(self, attr): - """Make a callback for cursor move event type, (e.g. "Start")""" - def cursor_move_event(): - cursor = self.textCursor() - move_type = getattr(QTextCursor, attr) - cursor.movePosition(move_type) - self.setTextCursor(cursor) - return cursor_move_event - - def create_shortcuts(self): - """Create the local shortcuts for the CodeEditor.""" - shortcut_context_name_callbacks = ( - ('editor', 'code completion', self.do_completion), - ('editor', 'duplicate line down', self.duplicate_line_down), - ('editor', 'duplicate line up', self.duplicate_line_up), - ('editor', 'delete line', self.delete_line), - ('editor', 'move line up', self.move_line_up), - ('editor', 'move line down', self.move_line_down), - ('editor', 'go to new line', self.go_to_new_line), - ('editor', 'go to definition', self.go_to_definition_from_cursor), - ('editor', 'toggle comment', self.toggle_comment), - ('editor', 'blockcomment', self.blockcomment), - ('editor', 'unblockcomment', self.unblockcomment), - ('editor', 'transform to uppercase', self.transform_to_uppercase), - ('editor', 'transform to lowercase', self.transform_to_lowercase), - ('editor', 'indent', lambda: self.indent(force=True)), - ('editor', 'unindent', lambda: self.unindent(force=True)), - ('editor', 'start of line', - self.create_cursor_callback('StartOfLine')), - ('editor', 'end of line', - self.create_cursor_callback('EndOfLine')), - ('editor', 'previous line', self.create_cursor_callback('Up')), - ('editor', 'next line', self.create_cursor_callback('Down')), - ('editor', 'previous char', self.create_cursor_callback('Left')), - ('editor', 'next char', self.create_cursor_callback('Right')), - ('editor', 'previous word', - self.create_cursor_callback('PreviousWord')), - ('editor', 'next word', self.create_cursor_callback('NextWord')), - ('editor', 'kill to line end', self.kill_line_end), - ('editor', 'kill to line start', self.kill_line_start), - ('editor', 'yank', self._kill_ring.yank), - ('editor', 'rotate kill ring', self._kill_ring.rotate), - ('editor', 'kill previous word', self.kill_prev_word), - ('editor', 'kill next word', self.kill_next_word), - ('editor', 'start of document', - self.create_cursor_callback('Start')), - ('editor', 'end of document', - self.create_cursor_callback('End')), - ('editor', 'undo', self.undo), - ('editor', 'redo', self.redo), - ('editor', 'cut', self.cut), - ('editor', 'copy', self.copy), - ('editor', 'paste', self.paste), - ('editor', 'delete', self.delete), - ('editor', 'select all', self.selectAll), - ('editor', 'docstring', - self.writer_docstring.write_docstring_for_shortcut), - ('editor', 'autoformatting', self.format_document_or_range), - ('array_builder', 'enter array inline', self.enter_array_inline), - ('array_builder', 'enter array table', self.enter_array_table) - ) - - shortcuts = [] - for context, name, callback in shortcut_context_name_callbacks: - shortcuts.append( - CONF.config_shortcut( - callback, context=context, name=name, parent=self)) - return shortcuts - - def get_shortcut_data(self): - """ - Returns shortcut data, a list of tuples (shortcut, text, default) - shortcut (QShortcut or QAction instance) - text (string): action/shortcut description - default (string): default key sequence - """ - return [sc.data for sc in self.shortcuts] - - def closeEvent(self, event): - if isinstance(self.highlighter, sh.PygmentsSH): - self.highlighter.stop() - self.update_folding_thread.quit() - self.update_folding_thread.wait() - self.update_diagnostics_thread.quit() - self.update_diagnostics_thread.wait() - TextEditBaseWidget.closeEvent(self, event) - - def get_document_id(self): - return self.document_id - - def set_as_clone(self, editor): - """Set as clone editor""" - self.setDocument(editor.document()) - self.document_id = editor.get_document_id() - self.highlighter = editor.highlighter - self._rehighlight_timer.timeout.connect( - self.highlighter.rehighlight) - self.eol_chars = editor.eol_chars - self._apply_highlighter_color_scheme() - self.highlighter.sig_font_changed.connect(self.sync_font) - - # ---- Widget setup and options - def toggle_wrap_mode(self, enable): - """Enable/disable wrap mode""" - self.set_wrap_mode('word' if enable else None) - - def toggle_line_numbers(self, linenumbers=True, markers=False): - """Enable/disable line numbers.""" - self.linenumberarea.setup_margins(linenumbers, markers) - - @property - def panels(self): - """ - Returns a reference to the - :class:`spyder.widgets.panels.managers.PanelsManager` - used to manage the collection of installed panels - """ - return self._panels - - def setup_editor(self, - linenumbers=True, - language=None, - markers=False, - font=None, - color_scheme=None, - wrap=False, - tab_mode=True, - strip_mode=False, - intelligent_backspace=True, - automatic_completions=True, - automatic_completions_after_chars=3, - automatic_completions_after_ms=300, - completions_hint=True, - completions_hint_after_ms=500, - hover_hints=True, - code_snippets=True, - highlight_current_line=True, - highlight_current_cell=True, - occurrence_highlighting=True, - scrollflagarea=True, - edge_line=True, - edge_line_columns=(79,), - show_blanks=False, - underline_errors=False, - close_parentheses=True, - close_quotes=False, - add_colons=True, - auto_unindent=True, - indent_chars=" "*4, - tab_stop_width_spaces=4, - cloned_from=None, - filename=None, - occurrence_timeout=1500, - show_class_func_dropdown=False, - indent_guides=False, - scroll_past_end=False, - show_debug_panel=True, - folding=True, - remove_trailing_spaces=False, - remove_trailing_newlines=False, - add_newline=False, - format_on_save=False): - """ - Set-up configuration for the CodeEditor instance. - - Usually the parameters here are related with a configurable preference - in the Preference Dialog and Editor configurations: - - linenumbers: Enable/Disable line number panel. Default True. - language: Set editor language for example python. Default None. - markers: Enable/Disable markers panel. Used to show elements like - Code Analysis. Default False. - font: Base font for the Editor to use. Default None. - color_scheme: Initial color scheme for the Editor to use. Default None. - wrap: Enable/Disable line wrap. Default False. - tab_mode: Enable/Disable using Tab as delimiter between word, - Default True. - strip_mode: strip_mode: Enable/Disable striping trailing spaces when - modifying the file. Default False. - intelligent_backspace: Enable/Disable automatically unindenting - inserted text (unindenting happens if the leading text length of - the line isn't module of the length of indentation chars being use) - Default True. - automatic_completions: Enable/Disable automatic completions. - The behavior of the trigger of this the completions can be - established with the two following kwargs. Default True. - automatic_completions_after_chars: Number of charts to type to trigger - an automatic completion. Default 3. - automatic_completions_after_ms: Number of milliseconds to pass before - an autocompletion is triggered. Default 300. - completions_hint: Enable/Disable documentation hints for completions. - Default True. - completions_hint_after_ms: Number of milliseconds over a completion - item to show the documentation hint. Default 500. - hover_hints: Enable/Disable documentation hover hints. Default True. - code_snippets: Enable/Disable code snippets completions. Default True. - highlight_current_line: Enable/Disable current line highlighting. - Default True. - highlight_current_cell: Enable/Disable current cell highlighting. - Default True. - occurrence_highlighting: Enable/Disable highlighting of current word - occurrence in the file. Default True. - scrollflagarea : Enable/Disable flag area that shows at the left of - the scroll bar. Default True. - edge_line: Enable/Disable vertical line to show max number of - characters per line. Customizable number of columns in the - following kwarg. Default True. - edge_line_columns: Number of columns/characters where the editor - horizontal edge line will show. Default (79,). - show_blanks: Enable/Disable blanks highlighting. Default False. - underline_errors: Enable/Disable showing and underline to highlight - errors. Default False. - close_parentheses: Enable/Disable automatic parentheses closing - insertion. Default True. - close_quotes: Enable/Disable automatic closing of quotes. - Default False. - add_colons: Enable/Disable automatic addition of colons. Default True. - auto_unindent: Enable/Disable automatically unindentation before else, - elif, finally or except statements. Default True. - indent_chars: Characters to use for indentation. Default " "*4. - tab_stop_width_spaces: Enable/Disable using tabs for indentation. - Default 4. - cloned_from: Editor instance used as template to instantiate this - CodeEditor instance. Default None. - filename: Initial filename to show. Default None. - occurrence_timeout : Timeout in milliseconds to start highlighting - matches/occurrences for the current word under the cursor. - Default 1500 ms. - show_class_func_dropdown: Enable/Disable a Matlab like widget to show - classes and functions available in the current file. Default False. - indent_guides: Enable/Disable highlighting of code indentation. - Default False. - scroll_past_end: Enable/Disable possibility to scroll file passed - its end. Default False. - show_debug_panel: Enable/Disable debug panel. Default True. - folding: Enable/Disable code folding. Default True. - remove_trailing_spaces: Remove trailing whitespaces on lines. - Default False. - remove_trailing_newlines: Remove extra lines at the end of the file. - Default False. - add_newline: Add a newline at the end of the file if there is not one. - Default False. - format_on_save: Autoformat file automatically when saving. - Default False. - """ - - self.set_close_parentheses_enabled(close_parentheses) - self.set_close_quotes_enabled(close_quotes) - self.set_add_colons_enabled(add_colons) - self.set_auto_unindent_enabled(auto_unindent) - self.set_indent_chars(indent_chars) - - # Show/hide the debug panel depending on the language and parameter - self.set_debug_panel(show_debug_panel, language) - - # Show/hide folding panel depending on parameter - self.toggle_code_folding(folding) - - # Scrollbar flag area - self.scrollflagarea.set_enabled(scrollflagarea) - - # Debugging - self.debugger.set_filename(filename) - - # Edge line - self.edge_line.set_enabled(edge_line) - self.edge_line.set_columns(edge_line_columns) - - # Indent guides - self.toggle_identation_guides(indent_guides) - if self.indent_chars == '\t': - self.indent_guides.set_indentation_width( - tab_stop_width_spaces) - else: - self.indent_guides.set_indentation_width(len(self.indent_chars)) - - # Blanks - self.set_blanks_enabled(show_blanks) - - # Remove trailing whitespaces - self.set_remove_trailing_spaces(remove_trailing_spaces) - - # Remove trailing newlines - self.set_remove_trailing_newlines(remove_trailing_newlines) - - # Add newline at the end - self.set_add_newline(add_newline) - - # Scrolling past the end - self.set_scrollpastend_enabled(scroll_past_end) - - # Line number area and indent guides - if cloned_from: - self.setFont(font) # this is required for line numbers area - # Needed to show indent guides for splited editor panels - # See spyder-ide/spyder#10900 - self.patch = cloned_from.patch - self.is_cloned = True - self.toggle_line_numbers(linenumbers, markers) - - # Lexer - self.filename = filename - self.set_language(language, filename) - - # Underline errors and warnings - self.set_underline_errors_enabled(underline_errors) - - # Highlight current cell - self.set_highlight_current_cell(highlight_current_cell) - - # Highlight current line - self.set_highlight_current_line(highlight_current_line) - - # Occurrence highlighting - self.set_occurrence_highlighting(occurrence_highlighting) - self.set_occurrence_timeout(occurrence_timeout) - - # Tab always indents (even when cursor is not at the begin of line) - self.set_tab_mode(tab_mode) - - # Intelligent backspace - self.toggle_intelligent_backspace(intelligent_backspace) - - # Automatic completions - self.toggle_automatic_completions(automatic_completions) - self.set_automatic_completions_after_chars( - automatic_completions_after_chars) - self.set_automatic_completions_after_ms(automatic_completions_after_ms) - - # Completions hint - self.toggle_completions_hint(completions_hint) - self.set_completions_hint_after_ms(completions_hint_after_ms) - - # Hover hints - self.toggle_hover_hints(hover_hints) - - # Code snippets - self.toggle_code_snippets(code_snippets) - - # Autoformat on save - self.toggle_format_on_save(format_on_save) - - if cloned_from is not None: - self.set_as_clone(cloned_from) - self.panels.refresh() - elif font is not None: - self.set_font(font, color_scheme) - elif color_scheme is not None: - self.set_color_scheme(color_scheme) - - # Set tab spacing after font is set - self.set_tab_stop_width_spaces(tab_stop_width_spaces) - - self.toggle_wrap_mode(wrap) - - # Class/Function dropdown will be disabled if we're not in a Python - # file. - self.classfuncdropdown.setVisible(show_class_func_dropdown - and self.is_python_like()) - - self.set_strip_mode(strip_mode) - - # --- Language Server Protocol methods ----------------------------------- - # ------------------------------------------------------------------------ - @Slot(str, dict) - def handle_response(self, method, params): - if method in self.handler_registry: - handler_name = self.handler_registry[method] - handler = getattr(self, handler_name) - handler(params) - # This signal is only used on tests. - # It could be used to track and profile LSP diagnostics. - self.completions_response_signal.emit(method, params) - - def emit_request(self, method, params, requires_response): - """Send request to LSP manager.""" - params['requires_response'] = requires_response - params['response_instance'] = self - self.sig_perform_completion_request.emit( - self.language.lower(), method, params) - - def log_lsp_handle_errors(self, message): - """ - Log errors when handling LSP responses. - - This works when debugging is on or off. - """ - if get_debug_level() > 0: - # We log the error normally when running on debug mode. - logger.error(message, exc_info=True) - else: - # We need this because logger.error activates our error - # report dialog but it doesn't show the entire traceback - # there. So we intentionally leave an error in this call - # to get the entire stack info generated by it, which - # gives the info we need from users. - if PY2: - logger.error(message, exc_info=True) - print(message, file=sys.stderr) - else: - logger.error('%', 1, stack_info=True) - - # ------------- LSP: Configuration and protocol start/end ---------------- - def start_completion_services(self): - """Start completion services for this instance.""" - self.completions_available = True - - if self.is_cloned: - additional_msg = " cloned editor" - else: - additional_msg = "" - self.document_did_open() - - logger.debug(u"Completion services available for {0}: {1}".format( - additional_msg, self.filename)) - - def register_completion_capabilities(self, capabilities): - """ - Register completion server capabilities. - - Parameters - ---------- - capabilities: dict - Capabilities supported by a language server. - """ - sync_options = capabilities['textDocumentSync'] - completion_options = capabilities['completionProvider'] - signature_options = capabilities['signatureHelpProvider'] - range_formatting_options = ( - capabilities['documentOnTypeFormattingProvider']) - self.open_close_notifications = sync_options.get('openClose', False) - self.sync_mode = sync_options.get('change', TextDocumentSyncKind.NONE) - self.will_save_notify = sync_options.get('willSave', False) - self.will_save_until_notify = sync_options.get('willSaveWaitUntil', - False) - self.save_include_text = sync_options['save']['includeText'] - self.enable_hover = capabilities['hoverProvider'] - self.folding_supported = capabilities.get( - 'foldingRangeProvider', False) - self.auto_completion_characters = ( - completion_options['triggerCharacters']) - self.resolve_completions_enabled = ( - completion_options.get('resolveProvider', False)) - self.signature_completion_characters = ( - signature_options['triggerCharacters'] + ['=']) # FIXME: - self.go_to_definition_enabled = capabilities['definitionProvider'] - self.find_references_enabled = capabilities['referencesProvider'] - self.highlight_enabled = capabilities['documentHighlightProvider'] - self.formatting_enabled = capabilities['documentFormattingProvider'] - self.range_formatting_enabled = ( - capabilities['documentRangeFormattingProvider']) - self.document_symbols_enabled = ( - capabilities['documentSymbolProvider'] - ) - self.formatting_characters.append( - range_formatting_options['firstTriggerCharacter']) - self.formatting_characters += ( - range_formatting_options.get('moreTriggerCharacter', [])) - - if self.formatting_enabled: - self.format_action.setEnabled(True) - self.sig_refresh_formatting.emit(True) - - self.completions_available = True - - def stop_completion_services(self): - logger.debug('Stopping completion services for %s' % self.filename) - self.completions_available = False - - @schedule_request(method=CompletionRequestTypes.DOCUMENT_DID_OPEN, - requires_response=False) - def document_did_open(self): - """Send textDocument/didOpen request to the server.""" - cursor = self.textCursor() - text = self.get_text_with_eol() - if self.is_ipython(): - # Send valid python text to LSP as it doesn't support IPython - text = self.ipython_to_python(text) - params = { - 'file': self.filename, - 'language': self.language, - 'version': self.text_version, - 'text': text, - 'codeeditor': self, - 'offset': cursor.position(), - 'selection_start': cursor.selectionStart(), - 'selection_end': cursor.selectionEnd(), - } - return params - - # ------------- LSP: Symbols --------------------------------------- - @schedule_request(method=CompletionRequestTypes.DOCUMENT_SYMBOL) - def request_symbols(self): - """Request document symbols.""" - if not self.document_symbols_enabled: - return - if self.oe_proxy is not None: - self.oe_proxy.emit_request_in_progress() - params = {'file': self.filename} - return params - - @handles(CompletionRequestTypes.DOCUMENT_SYMBOL) - def process_symbols(self, params): - """Handle symbols response.""" - try: - symbols = params['params'] - symbols = [] if symbols is None else symbols - self.classfuncdropdown.update_data(symbols) - if self.oe_proxy is not None: - self.oe_proxy.update_outline_info(symbols) - except RuntimeError: - # This is triggered when a codeeditor instance was removed - # before the response can be processed. - return - except Exception: - self.log_lsp_handle_errors("Error when processing symbols") - - # ------------- LSP: Linting --------------------------------------- - def schedule_document_did_change(self): - """Schedule a document update.""" - self._document_server_needs_update = True - self._server_requests_timer.start() - - @request( - method=CompletionRequestTypes.DOCUMENT_DID_CHANGE, - requires_response=False) - def document_did_change(self): - """Send textDocument/didChange request to the server.""" - # Cancel formatting - self.formatting_in_progress = False - text = self.get_text_with_eol() - if self.is_ipython(): - # Send valid python text to LSP - text = self.ipython_to_python(text) - - self.text_version += 1 - - self.patch = self.differ.patch_make(self.previous_text, text) - self.previous_text = text - cursor = self.textCursor() - params = { - 'file': self.filename, - 'version': self.text_version, - 'text': text, - 'diff': self.patch, - 'offset': cursor.position(), - 'selection_start': cursor.selectionStart(), - 'selection_end': cursor.selectionEnd(), - } - return params - - @handles(CompletionRequestTypes.DOCUMENT_PUBLISH_DIAGNOSTICS) - def process_diagnostics(self, params): - """Handle linting response.""" - # The LSP spec doesn't require that folding and symbols - # are treated in the same way as linting, i.e. to be - # recomputed on didChange, didOpen and didSave. However, - # we think that's necessary to maintain accurate folding - # and symbols all the time. Therefore, we decided to call - # those requests here, but after a certain timeout to - # avoid performance issues. - self._timer_sync_symbols_and_folding.start() - - # Process results (runs in a thread) - self.process_code_analysis(params['params']) - - def set_sync_symbols_and_folding_timeout(self): - """ - Set timeout to sync symbols and folding according to the file - size. - """ - current_lines = self.get_line_count() - timeout = None - - for lines in self.SYNC_SYMBOLS_AND_FOLDING_TIMEOUTS.keys(): - if (current_lines // lines) == 0: - timeout = self.SYNC_SYMBOLS_AND_FOLDING_TIMEOUTS[lines] - break - - if not timeout: - timeouts = self.SYNC_SYMBOLS_AND_FOLDING_TIMEOUTS.values() - timeout = list(timeouts)[-1] - - self._timer_sync_symbols_and_folding.setInterval(timeout) - - def sync_symbols_and_folding(self): - """ - Synchronize symbols and folding after linting results arrive. - """ - self.request_folding() - self.request_symbols() - - def process_code_analysis(self, diagnostics): - """Process code analysis results in a thread.""" - self.cleanup_code_analysis() - self._diagnostics = diagnostics - - # Process diagnostics in a thread to improve performance. - self.update_diagnostics_thread.start() - - def cleanup_code_analysis(self): - """Remove all code analysis markers""" - self.setUpdatesEnabled(False) - self.clear_extra_selections('code_analysis_highlight') - self.clear_extra_selections('code_analysis_underline') - for data in self.blockuserdata_list(): - data.code_analysis = [] - - self.setUpdatesEnabled(True) - # When the new code analysis results are empty, it is necessary - # to update manually the scrollflag and linenumber areas (otherwise, - # the old flags will still be displayed): - self.sig_flags_changed.emit() - self.linenumberarea.update() - - def set_errors(self): - """Set errors and warnings in the line number area.""" - try: - self._process_code_analysis(underline=False) - except RuntimeError: - # This is triggered when a codeeditor instance was removed - # before the response can be processed. - return - except Exception: - self.log_lsp_handle_errors("Error when processing linting") - - def underline_errors(self): - """Underline errors and warnings.""" - try: - # Clear current selections before painting the new ones. - # This prevents accumulating them when moving around in or editing - # the file, which generated a memory leakage and sluggishness - # after some time. - self.clear_extra_selections('code_analysis_underline') - self._process_code_analysis(underline=True) - except RuntimeError: - # This is triggered when a codeeditor instance was removed - # before the response can be processed. - return - except Exception: - self.log_lsp_handle_errors("Error when processing linting") - - def finish_code_analysis(self): - """Finish processing code analysis results.""" - self.linenumberarea.update() - if self.underline_errors_enabled: - self.underline_errors() - self.sig_process_code_analysis.emit() - self.sig_flags_changed.emit() - - def errors_present(self): - """ - Return True if there are errors or warnings present in the file. - """ - return bool(len(self._diagnostics)) - - def _process_code_analysis(self, underline): - """ - Process all code analysis results. - - Parameters - ---------- - underline: bool - Determines if errors and warnings are going to be set in - the line number area or underlined. It's better to separate - these two processes for perfomance reasons. That's because - setting errors can be done in a thread whereas underlining - them can't. - """ - document = self.document() - if underline: - first_block, last_block = self.get_buffer_block_numbers() - - for diagnostic in self._diagnostics: - if self.is_ipython() and ( - diagnostic["message"] == "undefined name 'get_ipython'"): - # get_ipython is defined in IPython files - continue - source = diagnostic.get('source', '') - msg_range = diagnostic['range'] - start = msg_range['start'] - end = msg_range['end'] - code = diagnostic.get('code', 'E') - message = diagnostic['message'] - severity = diagnostic.get( - 'severity', DiagnosticSeverity.ERROR) - - block = document.findBlockByNumber(start['line']) - text = block.text() - - # Skip messages according to certain criteria. - # This one works for any programming language - if 'analysis:ignore' in text: - continue - - # This only works for Python. - if self.language == 'Python': - if NOQA_INLINE_REGEXP.search(text) is not None: - continue - - data = block.userData() - if not data: - data = BlockUserData(self) - - if underline: - block_nb = block.blockNumber() - if first_block <= block_nb <= last_block: - error = severity == DiagnosticSeverity.ERROR - color = self.error_color if error else self.warning_color - color = QColor(color) - color.setAlpha(255) - block.color = color - - data.selection_start = start - data.selection_end = end - - self.highlight_selection('code_analysis_underline', - data._selection(), - underline_color=block.color) - else: - # Don't append messages to data for cloned editors to avoid - # showing them twice or more times on hover. - # Fixes spyder-ide/spyder#15618 - if not self.is_cloned: - data.code_analysis.append( - (source, code, severity, message) - ) - block.setUserData(data) - - # ------------- LSP: Completion --------------------------------------- - @schedule_request(method=CompletionRequestTypes.DOCUMENT_COMPLETION) - def do_completion(self, automatic=False): - """Trigger completion.""" - cursor = self.textCursor() - current_word = self.get_current_word( - completion=True, - valid_python_variable=False - ) - - params = { - 'file': self.filename, - 'line': cursor.blockNumber(), - 'column': cursor.columnNumber(), - 'offset': cursor.position(), - 'selection_start': cursor.selectionStart(), - 'selection_end': cursor.selectionEnd(), - 'current_word': current_word - } - self.completion_args = (self.textCursor().position(), automatic) - return params - - @handles(CompletionRequestTypes.DOCUMENT_COMPLETION) - def process_completion(self, params): - """Handle completion response.""" - args = self.completion_args - if args is None: - # This should not happen - return - self.completion_args = None - position, automatic = args - - start_cursor = self.textCursor() - start_cursor.movePosition(QTextCursor.StartOfBlock) - line_text = self.get_text(start_cursor.position(), 'eol') - leading_whitespace = self.compute_whitespace(line_text) - indentation_whitespace = ' ' * leading_whitespace - eol_char = self.get_line_separator() - - try: - completions = params['params'] - completions = ([] if completions is None else - [completion for completion in completions - if completion.get('insertText') - or completion.get('textEdit', {}).get('newText')]) - prefix = self.get_current_word(completion=True, - valid_python_variable=False) - if (len(completions) == 1 - and completions[0].get('insertText') == prefix - and not completions[0].get('textEdit', {}).get('newText')): - completions.pop() - - replace_end = self.textCursor().position() - under_cursor = self.get_current_word_and_position(completion=True) - if under_cursor: - word, replace_start = under_cursor - else: - word = '' - replace_start = replace_end - first_letter = '' - if len(word) > 0: - first_letter = word[0] - - def sort_key(completion): - if 'textEdit' in completion: - text_insertion = completion['textEdit']['newText'] - else: - text_insertion = completion['insertText'] - first_insert_letter = text_insertion[0] - case_mismatch = ( - (first_letter.isupper() and first_insert_letter.islower()) - or - (first_letter.islower() and first_insert_letter.isupper()) - ) - # False < True, so case matches go first - return (case_mismatch, completion['sortText']) - - completion_list = sorted(completions, key=sort_key) - - # Allow for textEdit completions to be filtered by Spyder - # if on-the-fly completions are disabled, only if the - # textEdit range matches the word under the cursor. - for completion in completion_list: - if 'textEdit' in completion: - c_replace_start = completion['textEdit']['range']['start'] - c_replace_end = completion['textEdit']['range']['end'] - if (c_replace_start == replace_start - and c_replace_end == replace_end): - insert_text = completion['textEdit']['newText'] - completion['filterText'] = insert_text - completion['insertText'] = insert_text - del completion['textEdit'] - - if 'insertText' in completion: - insert_text = completion['insertText'] - insert_text_lines = insert_text.splitlines() - reindented_text = [insert_text_lines[0]] - for insert_line in insert_text_lines[1:]: - insert_line = indentation_whitespace + insert_line - reindented_text.append(insert_line) - reindented_text = eol_char.join(reindented_text) - completion['insertText'] = reindented_text - - self.completion_widget.show_list( - completion_list, position, automatic) - - self.kite_call_to_action.handle_processed_completions(completions) - except RuntimeError: - # This is triggered when a codeeditor instance was removed - # before the response can be processed. - self.kite_call_to_action.hide_coverage_cta() - return - except Exception: - self.log_lsp_handle_errors('Error when processing completions') - - @schedule_request(method=CompletionRequestTypes.COMPLETION_RESOLVE) - def resolve_completion_item(self, item): - return { - 'file': self.filename, - 'completion_item': item - } - - @handles(CompletionRequestTypes.COMPLETION_RESOLVE) - def handle_completion_item_resolution(self, response): - try: - response = response['params'] - - if not response: - return - - self.completion_widget.augment_completion_info(response) - except RuntimeError: - # This is triggered when a codeeditor instance was removed - # before the response can be processed. - return - except Exception: - self.log_lsp_handle_errors( - "Error when handling completion item resolution") - - # ------------- LSP: Signature Hints ------------------------------------ - @schedule_request(method=CompletionRequestTypes.DOCUMENT_SIGNATURE) - def request_signature(self): - """Ask for signature.""" - line, column = self.get_cursor_line_column() - offset = self.get_position('cursor') - params = { - 'file': self.filename, - 'line': line, - 'column': column, - 'offset': offset - } - return params - - @handles(CompletionRequestTypes.DOCUMENT_SIGNATURE) - def process_signatures(self, params): - """Handle signature response.""" - try: - signature_params = params['params'] - - if (signature_params is not None and - 'activeParameter' in signature_params): - self.sig_signature_invoked.emit(signature_params) - signature_data = signature_params['signatures'] - documentation = signature_data['documentation'] - - if isinstance(documentation, dict): - documentation = documentation['value'] - - # The language server returns encoded text with - # spaces defined as `\xa0` - documentation = documentation.replace(u'\xa0', ' ') - - parameter_idx = signature_params['activeParameter'] - parameters = signature_data['parameters'] - parameter = None - if len(parameters) > 0 and parameter_idx < len(parameters): - parameter_data = parameters[parameter_idx] - parameter = parameter_data['label'] - - signature = signature_data['label'] - - # This method is part of spyder/widgets/mixins - self.show_calltip( - signature=signature, - parameter=parameter, - language=self.language, - documentation=documentation, - ) - except RuntimeError: - # This is triggered when a codeeditor instance was removed - # before the response can be processed. - return - except Exception: - self.log_lsp_handle_errors("Error when processing signature") - - # ------------- LSP: Hover/Mouse --------------------------------------- - @schedule_request(method=CompletionRequestTypes.DOCUMENT_CURSOR_EVENT) - def request_cursor_event(self): - text = self.get_text_with_eol() - cursor = self.textCursor() - params = { - 'file': self.filename, - 'version': self.text_version, - 'text': text, - 'offset': cursor.position(), - 'selection_start': cursor.selectionStart(), - 'selection_end': cursor.selectionEnd(), - } - return params - - @schedule_request(method=CompletionRequestTypes.DOCUMENT_HOVER) - def request_hover(self, line, col, offset, show_hint=True, clicked=True): - """Request hover information.""" - params = { - 'file': self.filename, - 'line': line, - 'column': col, - 'offset': offset - } - self._show_hint = show_hint - self._request_hover_clicked = clicked - return params - - @handles(CompletionRequestTypes.DOCUMENT_HOVER) - def handle_hover_response(self, contents): - """Handle hover response.""" - if running_under_pytest(): - from unittest.mock import Mock - - # On some tests this is returning a Mock - if isinstance(contents, Mock): - return - - try: - content = contents['params'] - - # - Don't display hover if there's no content to display. - # - Prevent spurious errors when a client returns a list. - if not content or isinstance(content, list): - return - - self.sig_display_object_info.emit( - content, - self._request_hover_clicked - ) - if content is not None and self._show_hint and self._last_point: - # This is located in spyder/widgets/mixins.py - word = self._last_hover_word - content = content.replace(u'\xa0', ' ') - self.show_hint(content, inspect_word=word, - at_point=self._last_point) - self._last_point = None - except RuntimeError: - # This is triggered when a codeeditor instance was removed - # before the response can be processed. - return - except Exception: - self.log_lsp_handle_errors("Error when processing hover") - - # ------------- LSP: Go To Definition ---------------------------- - @Slot() - @schedule_request(method=CompletionRequestTypes.DOCUMENT_DEFINITION) - def go_to_definition_from_cursor(self, cursor=None): - """Go to definition from cursor instance (QTextCursor).""" - if (not self.go_to_definition_enabled or - self.in_comment_or_string()): - return - - if cursor is None: - cursor = self.textCursor() - - text = to_text_string(cursor.selectedText()) - - if len(text) == 0: - cursor.select(QTextCursor.WordUnderCursor) - text = to_text_string(cursor.selectedText()) - - if text is not None: - line, column = self.get_cursor_line_column() - params = { - 'file': self.filename, - 'line': line, - 'column': column - } - return params - - @handles(CompletionRequestTypes.DOCUMENT_DEFINITION) - def handle_go_to_definition(self, position): - """Handle go to definition response.""" - try: - position = position['params'] - if position is not None: - def_range = position['range'] - start = def_range['start'] - if self.filename == position['file']: - self.go_to_line(start['line'] + 1, - start['character'], - None, - word=None) - else: - self.go_to_definition.emit(position['file'], - start['line'] + 1, - start['character']) - except RuntimeError: - # This is triggered when a codeeditor instance was removed - # before the response can be processed. - return - except Exception: - self.log_lsp_handle_errors( - "Error when processing go to definition") - - # ------------- LSP: Document/Selection formatting -------------------- - def format_document_or_range(self): - if self.has_selected_text() and self.range_formatting_enabled: - self.format_document_range() - else: - self.format_document() - - @schedule_request(method=CompletionRequestTypes.DOCUMENT_FORMATTING) - def format_document(self): - if not self.formatting_enabled: - return - if self.formatting_in_progress: - # Already waiting for a formatting - return - - using_spaces = self.indent_chars != '\t' - tab_size = (len(self.indent_chars) if using_spaces else - self.tab_stop_width_spaces) - params = { - 'file': self.filename, - 'options': { - 'tab_size': tab_size, - 'insert_spaces': using_spaces, - 'trim_trailing_whitespace': self.remove_trailing_spaces, - 'insert_final_new_line': self.add_newline, - 'trim_final_new_lines': self.remove_trailing_newlines - } - } - - # Sets the document into read-only and updates its corresponding - # tab name to display the filename into parenthesis - self.setReadOnly(True) - self.document().setModified(True) - self.sig_start_operation_in_progress.emit() - self.operation_in_progress = True - self.formatting_in_progress = True - - return params - - @schedule_request(method=CompletionRequestTypes.DOCUMENT_RANGE_FORMATTING) - def format_document_range(self): - if not self.range_formatting_enabled or not self.has_selected_text(): - return - if self.formatting_in_progress: - # Already waiting for a formatting - return - - start, end = self.get_selection_start_end() - start_line, start_col = start - end_line, end_col = end - using_spaces = self.indent_chars != '\t' - tab_size = (len(self.indent_chars) if using_spaces else - self.tab_stop_width_spaces) - - fmt_range = { - 'start': { - 'line': start_line, - 'character': start_col - }, - 'end': { - 'line': end_line, - 'character': end_col - } - } - params = { - 'file': self.filename, - 'range': fmt_range, - 'options': { - 'tab_size': tab_size, - 'insert_spaces': using_spaces, - 'trim_trailing_whitespace': self.remove_trailing_spaces, - 'insert_final_new_line': self.add_newline, - 'trim_final_new_lines': self.remove_trailing_newlines - } - } - - # Sets the document into read-only and updates its corresponding - # tab name to display the filename into parenthesis - self.setReadOnly(True) - self.document().setModified(True) - self.sig_start_operation_in_progress.emit() - self.operation_in_progress = True - self.formatting_in_progress = True - - return params - - @handles(CompletionRequestTypes.DOCUMENT_FORMATTING) - def handle_document_formatting(self, edits): - try: - if self.formatting_in_progress: - self._apply_document_edits(edits) - except RuntimeError: - # This is triggered when a codeeditor instance was removed - # before the response can be processed. - return - except Exception: - self.log_lsp_handle_errors("Error when processing document " - "formatting") - finally: - # Remove read-only parenthesis and highlight document modification - self.setReadOnly(False) - self.document().setModified(False) - self.document().setModified(True) - self.sig_stop_operation_in_progress.emit() - self.operation_in_progress = False - self.formatting_in_progress = False - - @handles(CompletionRequestTypes.DOCUMENT_RANGE_FORMATTING) - def handle_document_range_formatting(self, edits): - try: - if self.formatting_in_progress: - self._apply_document_edits(edits) - except RuntimeError: - # This is triggered when a codeeditor instance was removed - # before the response can be processed. - return - except Exception: - self.log_lsp_handle_errors("Error when processing document " - "selection formatting") - finally: - # Remove read-only parenthesis and highlight document modification - self.setReadOnly(False) - self.document().setModified(False) - self.document().setModified(True) - self.sig_stop_operation_in_progress.emit() - self.operation_in_progress = False - self.formatting_in_progress = False - - def _apply_document_edits(self, edits): - """Apply a set of atomic document edits to the current editor text.""" - edits = edits['params'] - if edits is None: - return - - # We need to use here toPlainText (which returns text with '\n' - # for eols) and not get_text_with_eol, so that applying the - # text edits that come from the LSP in the way implemented below - # works as expected. That's because we assume eol chars of length - # one in our algorithm. - # Fixes spyder-ide/spyder#16180 - text = self.toPlainText() - - text_tokens = list(text) - merged_text = None - for edit in edits: - edit_range = edit['range'] - repl_text = edit['newText'] - start, end = edit_range['start'], edit_range['end'] - start_line, start_col = start['line'], start['character'] - end_line, end_col = end['line'], end['character'] - - start_pos = self.get_position_line_number(start_line, start_col) - end_pos = self.get_position_line_number(end_line, end_col) - - # Replace repl_text eols for '\n' to match the ones used in - # `text`. - repl_eol = sourcecode.get_eol_chars(repl_text) - if repl_eol is not None and repl_eol != '\n': - repl_text = repl_text.replace(repl_eol, '\n') - - text_tokens = list(text_tokens) - this_edit = list(repl_text) - - if end_line == self.document().blockCount(): - end_pos = self.get_position('eof') - end_pos += 1 - - if (end_pos == len(text_tokens) and - text_tokens[end_pos - 1] == '\n'): - end_pos += 1 - - this_edition = (text_tokens[:max(start_pos - 1, 0)] + - this_edit + - text_tokens[end_pos - 1:]) - - text_edit = ''.join(this_edition) - if merged_text is None: - merged_text = text_edit - else: - merged_text = merge(text_edit, merged_text, text) - - if merged_text is not None: - # Restore eol chars after applying edits. - merged_text = merged_text.replace('\n', self.get_line_separator()) - - cursor = self.textCursor() - cursor.beginEditBlock() - cursor.movePosition(QTextCursor.Start) - cursor.movePosition(QTextCursor.End, - QTextCursor.KeepAnchor) - cursor.insertText(merged_text) - cursor.endEditBlock() - - # ------------- LSP: Code folding ranges ------------------------------- - def compute_whitespace(self, line): - tab_size = self.tab_stop_width_spaces - whitespace_regex = re.compile(r'(\s+).*') - whitespace_match = whitespace_regex.match(line) - total_whitespace = 0 - if whitespace_match is not None: - whitespace_chars = whitespace_match.group(1) - whitespace_chars = whitespace_chars.replace( - '\t', tab_size * ' ') - total_whitespace = len(whitespace_chars) - return total_whitespace - - def update_whitespace_count(self, line, column): - self.leading_whitespaces = {} - lines = to_text_string(self.toPlainText()).splitlines() - for i, text in enumerate(lines): - total_whitespace = self.compute_whitespace(text) - self.leading_whitespaces[i] = total_whitespace - - def cleanup_folding(self): - """Cleanup folding pane.""" - folding_panel = self.panels.get(FoldingPanel) - folding_panel.folding_regions = {} - - @schedule_request(method=CompletionRequestTypes.DOCUMENT_FOLDING_RANGE) - def request_folding(self): - """Request folding.""" - if not self.folding_supported or not self.code_folding: - return - params = {'file': self.filename} - return params - - @handles(CompletionRequestTypes.DOCUMENT_FOLDING_RANGE) - def handle_folding_range(self, response): - """Handle folding response.""" - ranges = response['params'] - if ranges is None: - return - - # Compute extended_ranges here because get_text_region ends up - # calling paintEvent and that method can't be called in a - # thread due to Qt restrictions. - try: - extended_ranges = [] - for start, end in ranges: - text_region = self.get_text_region(start, end) - extended_ranges.append((start, end, text_region)) - except RuntimeError: - # This is triggered when a codeeditor instance was removed - # before the response can be processed. - return - except Exception: - self.log_lsp_handle_errors("Error when processing folding") - - # Update folding in a thread - self.update_folding_thread.run = functools.partial( - self.update_and_merge_folding, extended_ranges) - self.update_folding_thread.start() - - def update_and_merge_folding(self, extended_ranges): - """Update and merge new folding information.""" - try: - folding_panel = self.panels.get(FoldingPanel) - - current_tree, root = merge_folding( - extended_ranges, folding_panel.current_tree, - folding_panel.root) - - folding_info = collect_folding_regions(root) - self._folding_info = (current_tree, root, *folding_info) - except RuntimeError: - # This is triggered when a codeeditor instance was removed - # before the response can be processed. - return - except Exception: - self.log_lsp_handle_errors("Error when processing folding") - - def finish_code_folding(self): - """Finish processing code folding.""" - folding_panel = self.panels.get(FoldingPanel) - folding_panel.update_folding(self._folding_info) - - # Update indent guides, which depend on folding - if self.indent_guides._enabled and len(self.patch) > 0: - line, column = self.get_cursor_line_column() - self.update_whitespace_count(line, column) - - # ------------- LSP: Save/close file ----------------------------------- - @schedule_request(method=CompletionRequestTypes.DOCUMENT_DID_SAVE, - requires_response=False) - def notify_save(self): - """Send save request.""" - params = {'file': self.filename} - if self.save_include_text: - params['text'] = self.get_text_with_eol() - return params - - @request(method=CompletionRequestTypes.DOCUMENT_DID_CLOSE, - requires_response=False) - def notify_close(self): - """Send close request.""" - self._pending_server_requests = [] - self._server_requests_timer.stop() - if self.completions_available: - # This is necessary to prevent an error in our tests. - try: - # Servers can send an empty publishDiagnostics reply to clear - # diagnostics after they receive a didClose request. Since - # we also ask for symbols and folding when processing - # diagnostics, we need to prevent it from happening - # before sending that request here. - self._timer_sync_symbols_and_folding.timeout.disconnect( - self.sync_symbols_and_folding) - except (TypeError, RuntimeError): - pass - - params = { - 'file': self.filename, - 'codeeditor': self - } - return params - - # ------------------------------------------------------------------------- - def set_debug_panel(self, show_debug_panel, language): - """Enable/disable debug panel.""" - debugger_panel = self.panels.get(DebuggerPanel) - if (is_text_string(language) and - language.lower() in ALL_LANGUAGES['Python'] and - show_debug_panel): - debugger_panel.setVisible(True) - else: - debugger_panel.setVisible(False) - - def update_debugger_panel_state(self, state, last_step, force=False): - """Update debugger panel state.""" - debugger_panel = self.panels.get(DebuggerPanel) - if force: - debugger_panel.start_clean() - return - elif state and 'fname' in last_step: - fname = last_step['fname'] - if (fname and self.filename - and osp.normcase(fname) == osp.normcase(self.filename)): - debugger_panel.start_clean() - return - debugger_panel.stop_clean() - - def set_folding_panel(self, folding): - """Enable/disable folding panel.""" - folding_panel = self.panels.get(FoldingPanel) - folding_panel.setVisible(folding) - - def set_tab_mode(self, enable): - """ - enabled = tab always indent - (otherwise tab indents only when cursor is at the beginning of a line) - """ - self.tab_mode = enable - - def set_strip_mode(self, enable): - """ - Strip all trailing spaces if enabled. - """ - self.strip_trailing_spaces_on_modify = enable - - def toggle_intelligent_backspace(self, state): - self.intelligent_backspace = state - - def toggle_automatic_completions(self, state): - self.automatic_completions = state - - def toggle_hover_hints(self, state): - self.hover_hints_enabled = state - - def toggle_code_snippets(self, state): - self.code_snippets = state - - def toggle_format_on_save(self, state): - self.format_on_save = state - - def toggle_code_folding(self, state): - self.code_folding = state - self.set_folding_panel(state) - if not state and self.indent_guides._enabled: - self.code_folding = True - - def toggle_identation_guides(self, state): - if state and not self.code_folding: - self.code_folding = True - self.indent_guides.set_enabled(state) - - def toggle_completions_hint(self, state): - """Enable/disable completion hint.""" - self.completions_hint = state - - def set_automatic_completions_after_chars(self, number): - """ - Set the number of characters after which auto completion is fired. - """ - self.automatic_completions_after_chars = number - - def set_automatic_completions_after_ms(self, ms): - """ - Set the amount of time in ms after which auto completion is fired. - """ - self.automatic_completions_after_ms = ms - - def set_completions_hint_after_ms(self, ms): - """ - Set the amount of time in ms after which the completions hint is shown. - """ - self.completions_hint_after_ms = ms - - def set_close_parentheses_enabled(self, enable): - """Enable/disable automatic parentheses insertion feature""" - self.close_parentheses_enabled = enable - bracket_extension = self.editor_extensions.get(CloseBracketsExtension) - if bracket_extension is not None: - bracket_extension.enabled = enable - - def set_close_quotes_enabled(self, enable): - """Enable/disable automatic quote insertion feature""" - self.close_quotes_enabled = enable - quote_extension = self.editor_extensions.get(CloseQuotesExtension) - if quote_extension is not None: - quote_extension.enabled = enable - - def set_add_colons_enabled(self, enable): - """Enable/disable automatic colons insertion feature""" - self.add_colons_enabled = enable - - def set_auto_unindent_enabled(self, enable): - """Enable/disable automatic unindent after else/elif/finally/except""" - self.auto_unindent_enabled = enable - - def set_occurrence_highlighting(self, enable): - """Enable/disable occurrence highlighting""" - self.occurrence_highlighting = enable - if not enable: - self.__clear_occurrences() - - def set_occurrence_timeout(self, timeout): - """Set occurrence highlighting timeout (ms)""" - self.occurrence_timer.setInterval(timeout) - - def set_underline_errors_enabled(self, state): - """Toggle the underlining of errors and warnings.""" - self.underline_errors_enabled = state - if not state: - self.clear_extra_selections('code_analysis_underline') - - def set_highlight_current_line(self, enable): - """Enable/disable current line highlighting""" - self.highlight_current_line_enabled = enable - if self.highlight_current_line_enabled: - self.highlight_current_line() - else: - self.unhighlight_current_line() - - def set_highlight_current_cell(self, enable): - """Enable/disable current line highlighting""" - hl_cell_enable = enable and self.supported_cell_language - self.highlight_current_cell_enabled = hl_cell_enable - if self.highlight_current_cell_enabled: - self.highlight_current_cell() - else: - self.unhighlight_current_cell() - - def set_language(self, language, filename=None): - extra_supported_languages = {'stil': 'STIL'} - self.tab_indents = language in self.TAB_ALWAYS_INDENTS - self.comment_string = '' - self.language = 'Text' - self.supported_language = False - sh_class = sh.TextSH - language = 'None' if language is None else language - if language is not None: - for (key, value) in ALL_LANGUAGES.items(): - if language.lower() in value: - self.supported_language = True - sh_class, comment_string = self.LANGUAGES[key] - if key == 'IPython': - self.language = 'Python' - else: - self.language = key - self.comment_string = comment_string - if key in CELL_LANGUAGES: - self.supported_cell_language = True - self.has_cell_separators = True - break - - if filename is not None and not self.supported_language: - sh_class = sh.guess_pygments_highlighter(filename) - self.support_language = sh_class is not sh.TextSH - if self.support_language: - # Pygments report S for the lexer name of R files - if sh_class._lexer.name == 'S': - self.language = 'R' - else: - self.language = sh_class._lexer.name - else: - _, ext = osp.splitext(filename) - ext = ext.lower() - if ext in extra_supported_languages: - self.language = extra_supported_languages[ext] - - self._set_highlighter(sh_class) - self.completion_widget.set_language(self.language) - - def _set_highlighter(self, sh_class): - self.highlighter_class = sh_class - if self.highlighter is not None: - # Removing old highlighter - # TODO: test if leaving parent/document as is eats memory - self.highlighter.setParent(None) - self.highlighter.setDocument(None) - self.highlighter = self.highlighter_class(self.document(), - self.font(), - self.color_scheme) - self._apply_highlighter_color_scheme() - - self.highlighter.editor = self - self.highlighter.sig_font_changed.connect(self.sync_font) - self._rehighlight_timer.timeout.connect( - self.highlighter.rehighlight) - - def sync_font(self): - """Highlighter changed font, update.""" - self.setFont(self.highlighter.font) - self.sig_font_changed.emit() - - def get_cell_list(self): - """Get all cells.""" - if self.highlighter is None: - return [] - - # Filter out old cells - def good(oedata): - return oedata.is_valid() and oedata.def_type == oedata.CELL - - self.highlighter._cell_list = [ - oedata for oedata in self.highlighter._cell_list if good(oedata)] - - return sorted( - {oedata.block.blockNumber(): oedata - for oedata in self.highlighter._cell_list}.items()) - - def is_json(self): - return (isinstance(self.highlighter, sh.PygmentsSH) and - self.highlighter._lexer.name == 'JSON') - - def is_python(self): - return self.highlighter_class is sh.PythonSH - - def is_ipython(self): - return self.highlighter_class is sh.IPythonSH - - def is_python_or_ipython(self): - return self.is_python() or self.is_ipython() - - def is_cython(self): - return self.highlighter_class is sh.CythonSH - - def is_enaml(self): - return self.highlighter_class is sh.EnamlSH - - def is_python_like(self): - return (self.is_python() or self.is_ipython() - or self.is_cython() or self.is_enaml()) - - def intelligent_tab(self): - """Provide intelligent behavior for Tab key press.""" - leading_text = self.get_text('sol', 'cursor') - if not leading_text.strip() or leading_text.endswith('#'): - # blank line or start of comment - self.indent_or_replace() - elif self.in_comment_or_string() and not leading_text.endswith(' '): - # in a word in a comment - self.do_completion() - elif leading_text.endswith('import ') or leading_text[-1] == '.': - # blank import or dot completion - self.do_completion() - elif (leading_text.split()[0] in ['from', 'import'] and - ';' not in leading_text): - # import line with a single statement - # (prevents lines like: `import pdb; pdb.set_trace()`) - self.do_completion() - elif leading_text[-1] in '(,' or leading_text.endswith(', '): - self.indent_or_replace() - elif leading_text.endswith(' '): - # if the line ends with a space, indent - self.indent_or_replace() - elif re.search(r"[^\d\W]\w*\Z", leading_text, re.UNICODE): - # if the line ends with a non-whitespace character - self.do_completion() - else: - self.indent_or_replace() - - def intelligent_backtab(self): - """Provide intelligent behavior for Shift+Tab key press""" - leading_text = self.get_text('sol', 'cursor') - if not leading_text.strip(): - # blank line - self.unindent() - elif self.in_comment_or_string(): - self.unindent() - elif leading_text[-1] in '(,' or leading_text.endswith(', '): - position = self.get_position('cursor') - self.show_object_info(position) - else: - # if the line ends with any other character but comma - self.unindent() - - def rehighlight(self): - """Rehighlight the whole document.""" - if self.highlighter is not None: - self.highlighter.rehighlight() - if self.highlight_current_cell_enabled: - self.highlight_current_cell() - else: - self.unhighlight_current_cell() - if self.highlight_current_line_enabled: - self.highlight_current_line() - else: - self.unhighlight_current_line() - - def trim_trailing_spaces(self): - """Remove trailing spaces""" - cursor = self.textCursor() - cursor.beginEditBlock() - cursor.movePosition(QTextCursor.Start) - while True: - cursor.movePosition(QTextCursor.EndOfBlock) - text = to_text_string(cursor.block().text()) - length = len(text)-len(text.rstrip()) - if length > 0: - cursor.movePosition(QTextCursor.Left, QTextCursor.KeepAnchor, - length) - cursor.removeSelectedText() - if cursor.atEnd(): - break - cursor.movePosition(QTextCursor.NextBlock) - cursor.endEditBlock() - - def trim_trailing_newlines(self): - """Remove extra newlines at the end of the document.""" - cursor = self.textCursor() - cursor.beginEditBlock() - cursor.movePosition(QTextCursor.End) - line = cursor.blockNumber() - this_line = self.get_text_line(line) - previous_line = self.get_text_line(line - 1) - - # Don't try to trim new lines for a file with a single line. - # Fixes spyder-ide/spyder#16401 - if self.get_line_count() > 1: - while this_line == '': - cursor.movePosition(QTextCursor.PreviousBlock, - QTextCursor.KeepAnchor) - - if self.add_newline: - if this_line == '' and previous_line != '': - cursor.movePosition(QTextCursor.NextBlock, - QTextCursor.KeepAnchor) - - line -= 1 - if line == 0: - break - - this_line = self.get_text_line(line) - previous_line = self.get_text_line(line - 1) - - if not self.add_newline: - cursor.movePosition(QTextCursor.EndOfBlock, - QTextCursor.KeepAnchor) - - cursor.removeSelectedText() - cursor.endEditBlock() - - def add_newline_to_file(self): - """Add a newline to the end of the file if it does not exist.""" - cursor = self.textCursor() - cursor.movePosition(QTextCursor.End) - line = cursor.blockNumber() - this_line = self.get_text_line(line) - if this_line != '': - cursor.beginEditBlock() - cursor.movePosition(QTextCursor.EndOfBlock) - cursor.insertText(self.get_line_separator()) - cursor.endEditBlock() - - def fix_indentation(self): - """Replace tabs by spaces.""" - text_before = to_text_string(self.toPlainText()) - text_after = sourcecode.fix_indentation(text_before, self.indent_chars) - if text_before != text_after: - # We do the following rather than using self.setPlainText - # to benefit from QTextEdit's undo/redo feature. - self.selectAll() - self.skip_rstrip = True - self.insertPlainText(text_after) - self.skip_rstrip = False - - def get_current_object(self): - """Return current object (string) """ - source_code = to_text_string(self.toPlainText()) - offset = self.get_position('cursor') - return sourcecode.get_primary_at(source_code, offset) - - def next_cursor_position(self, position=None, - mode=QTextLayout.SkipCharacters): - """ - Get next valid cursor position. - - Adapted from: - https://github.com/qt/qtbase/blob/5.15.2/src/gui/text/qtextdocument_p.cpp#L1361 - """ - cursor = self.textCursor() - if cursor.atEnd(): - return position - if position is None: - position = cursor.position() - else: - cursor.setPosition(position) - it = cursor.block() - start = it.position() - end = start + it.length() - 1 - if (position == end): - return end + 1 - return it.layout().nextCursorPosition(position - start, mode) + start - - @Slot() - def delete(self): - """Remove selected text or next character.""" - if not self.has_selected_text(): - cursor = self.textCursor() - if not cursor.atEnd(): - cursor.setPosition( - self.next_cursor_position(), QTextCursor.KeepAnchor) - self.setTextCursor(cursor) - self.remove_selected_text() - - #------Find occurrences - def __find_first(self, text): - """Find first occurrence: scan whole document""" - flags = QTextDocument.FindCaseSensitively|QTextDocument.FindWholeWords - cursor = self.textCursor() - # Scanning whole document - cursor.movePosition(QTextCursor.Start) - regexp = QRegExp(r"\b%s\b" % QRegExp.escape(text), Qt.CaseSensitive) - cursor = self.document().find(regexp, cursor, flags) - self.__find_first_pos = cursor.position() - return cursor - - def __find_next(self, text, cursor): - """Find next occurrence""" - flags = QTextDocument.FindCaseSensitively|QTextDocument.FindWholeWords - regexp = QRegExp(r"\b%s\b" % QRegExp.escape(text), Qt.CaseSensitive) - cursor = self.document().find(regexp, cursor, flags) - if cursor.position() != self.__find_first_pos: - return cursor - - def __cursor_position_changed(self): - """Cursor position has changed""" - line, column = self.get_cursor_line_column() - self.sig_cursor_position_changed.emit(line, column) - - if self.highlight_current_cell_enabled: - self.highlight_current_cell() - else: - self.unhighlight_current_cell() - if self.highlight_current_line_enabled: - self.highlight_current_line() - else: - self.unhighlight_current_line() - if self.occurrence_highlighting: - self.occurrence_timer.start() - - # Strip if needed - self.strip_trailing_spaces() - - def __clear_occurrences(self): - """Clear occurrence markers""" - self.occurrences = [] - self.clear_extra_selections('occurrences') - self.sig_flags_changed.emit() - - def get_selection(self, cursor, foreground_color=None, - background_color=None, underline_color=None, - outline_color=None, - underline_style=QTextCharFormat.SingleUnderline): - """Get selection.""" - if cursor is None: - return - - selection = TextDecoration(cursor) - if foreground_color is not None: - selection.format.setForeground(foreground_color) - if background_color is not None: - selection.format.setBackground(background_color) - if underline_color is not None: - selection.format.setProperty(QTextFormat.TextUnderlineStyle, - to_qvariant(underline_style)) - selection.format.setProperty(QTextFormat.TextUnderlineColor, - to_qvariant(underline_color)) - if outline_color is not None: - selection.set_outline(outline_color) - return selection - - def highlight_selection(self, key, cursor, foreground_color=None, - background_color=None, underline_color=None, - outline_color=None, - underline_style=QTextCharFormat.SingleUnderline): - - selection = self.get_selection( - cursor, foreground_color, background_color, underline_color, - outline_color, underline_style) - if selection is None: - return - extra_selections = self.get_extra_selections(key) - extra_selections.append(selection) - self.set_extra_selections(key, extra_selections) - - def __mark_occurrences(self): - """Marking occurrences of the currently selected word""" - self.__clear_occurrences() - - if not self.supported_language: - return - - text = self.get_selected_text().strip() - if not text: - text = self.get_current_word() - if text is None: - return - if (self.has_selected_text() and - self.get_selected_text().strip() != text): - return - - if (self.is_python_like() and - (sourcecode.is_keyword(to_text_string(text)) or - to_text_string(text) == 'self')): - return - - # Highlighting all occurrences of word *text* - cursor = self.__find_first(text) - self.occurrences = [] - extra_selections = self.get_extra_selections('occurrences') - first_occurrence = None - while cursor: - block = cursor.block() - if not block.userData(): - # Add user data to check block validity - block.setUserData(BlockUserData(self)) - self.occurrences.append(block) - - selection = self.get_selection(cursor) - if len(selection.cursor.selectedText()) > 0: - extra_selections.append(selection) - if len(extra_selections) == 1: - first_occurrence = selection - else: - selection.format.setBackground(self.occurrence_color) - first_occurrence.format.setBackground( - self.occurrence_color) - cursor = self.__find_next(text, cursor) - self.set_extra_selections('occurrences', extra_selections) - - if len(self.occurrences) > 1 and self.occurrences[-1] == 0: - # XXX: this is never happening with PySide but it's necessary - # for PyQt4... this must be related to a different behavior for - # the QTextDocument.find function between those two libraries - self.occurrences.pop(-1) - self.sig_flags_changed.emit() - - #-----highlight found results (find/replace widget) - def highlight_found_results(self, pattern, word=False, regexp=False, - case=False): - """Highlight all found patterns""" - pattern = to_text_string(pattern) - if not pattern: - return - if not regexp: - pattern = re.escape(to_text_string(pattern)) - pattern = r"\b%s\b" % pattern if word else pattern - text = to_text_string(self.toPlainText()) - re_flags = re.MULTILINE if case else re.IGNORECASE | re.MULTILINE - try: - regobj = re.compile(pattern, flags=re_flags) - except sre_constants.error: - return - extra_selections = [] - self.found_results = [] - has_unicode = len(text) != qstring_length(text) - for match in regobj.finditer(text): - if has_unicode: - pos1, pos2 = sh.get_span(match) - else: - pos1, pos2 = match.span() - selection = TextDecoration(self.textCursor()) - selection.format.setBackground(self.found_results_color) - selection.cursor.setPosition(pos1) - - block = selection.cursor.block() - if not block.userData(): - # Add user data to check block validity - block.setUserData(BlockUserData(self)) - self.found_results.append(block) - - selection.cursor.setPosition(pos2, QTextCursor.KeepAnchor) - extra_selections.append(selection) - self.set_extra_selections('find', extra_selections) - - def clear_found_results(self): - """Clear found results highlighting""" - self.found_results = [] - self.clear_extra_selections('find') - self.sig_flags_changed.emit() - - def __text_has_changed(self): - """Text has changed, eventually clear found results highlighting""" - self.last_change_position = self.textCursor().position() - if self.found_results: - self.clear_found_results() - - def get_linenumberarea_width(self): - """ - Return current line number area width. - - This method is left for backward compatibility (BaseEditMixin - define it), any changes should be in LineNumberArea class. - """ - return self.linenumberarea.get_width() - - def calculate_real_position(self, point): - """Add offset to a point, to take into account the panels.""" - point.setX(point.x() + self.panels.margin_size(Panel.Position.LEFT)) - point.setY(point.y() + self.panels.margin_size(Panel.Position.TOP)) - return point - - def calculate_real_position_from_global(self, point): - """Add offset to a point, to take into account the panels.""" - point.setX(point.x() - self.panels.margin_size(Panel.Position.LEFT)) - point.setY(point.y() + self.panels.margin_size(Panel.Position.TOP)) - return point - - def get_linenumber_from_mouse_event(self, event): - """Return line number from mouse event""" - block = self.firstVisibleBlock() - line_number = block.blockNumber() - top = self.blockBoundingGeometry(block).translated( - self.contentOffset()).top() - bottom = top + self.blockBoundingRect(block).height() - while block.isValid() and top < event.pos().y(): - block = block.next() - if block.isVisible(): # skip collapsed blocks - top = bottom - bottom = top + self.blockBoundingRect(block).height() - line_number += 1 - return line_number - - def select_lines(self, linenumber_pressed, linenumber_released): - """Select line(s) after a mouse press/mouse press drag event""" - find_block_by_number = self.document().findBlockByNumber - move_n_blocks = (linenumber_released - linenumber_pressed) - start_line = linenumber_pressed - start_block = find_block_by_number(start_line - 1) - - cursor = self.textCursor() - cursor.setPosition(start_block.position()) - - # Select/drag downwards - if move_n_blocks > 0: - for n in range(abs(move_n_blocks) + 1): - cursor.movePosition(cursor.NextBlock, cursor.KeepAnchor) - # Select/drag upwards or select single line - else: - cursor.movePosition(cursor.NextBlock) - for n in range(abs(move_n_blocks) + 1): - cursor.movePosition(cursor.PreviousBlock, cursor.KeepAnchor) - - # Account for last line case - if linenumber_released == self.blockCount(): - cursor.movePosition(cursor.EndOfBlock, cursor.KeepAnchor) - else: - cursor.movePosition(cursor.StartOfBlock, cursor.KeepAnchor) - - self.setTextCursor(cursor) - - # ----- Code bookmarks - def add_bookmark(self, slot_num, line=None, column=None): - """Add bookmark to current block's userData.""" - if line is None: - # Triggered by shortcut, else by spyder start - line, column = self.get_cursor_line_column() - block = self.document().findBlockByNumber(line) - data = block.userData() - if not data: - data = BlockUserData(self) - if slot_num not in data.bookmarks: - data.bookmarks.append((slot_num, column)) - block.setUserData(data) - self._bookmarks_blocks[id(block)] = block - self.sig_bookmarks_changed.emit() - - def get_bookmarks(self): - """Get bookmarks by going over all blocks.""" - bookmarks = {} - pruned_bookmarks_blocks = {} - for block_id in self._bookmarks_blocks: - block = self._bookmarks_blocks[block_id] - if block.isValid(): - data = block.userData() - if data and data.bookmarks: - pruned_bookmarks_blocks[block_id] = block - line_number = block.blockNumber() - for slot_num, column in data.bookmarks: - bookmarks[slot_num] = [line_number, column] - self._bookmarks_blocks = pruned_bookmarks_blocks - return bookmarks - - def clear_bookmarks(self): - """Clear bookmarks for all blocks.""" - self.bookmarks = {} - for data in self.blockuserdata_list(): - data.bookmarks = [] - self._bookmarks_blocks = {} - - def set_bookmarks(self, bookmarks): - """Set bookmarks when opening file.""" - self.clear_bookmarks() - for slot_num, bookmark in bookmarks.items(): - self.add_bookmark(slot_num, bookmark[1], bookmark[2]) - - def update_bookmarks(self): - """Emit signal to update bookmarks.""" - self.sig_bookmarks_changed.emit() - - # -----Code introspection - def show_completion_object_info(self, name, signature): - """Trigger show completion info in Help Pane.""" - force = True - self.sig_show_completion_object_info.emit(name, signature, force) - - def show_object_info(self, position): - """Trigger a calltip""" - self.sig_show_object_info.emit(position) - - # -----blank spaces - def set_blanks_enabled(self, state): - """Toggle blanks visibility""" - self.blanks_enabled = state - option = self.document().defaultTextOption() - option.setFlags(option.flags() | \ - QTextOption.AddSpaceForLineAndParagraphSeparators) - if self.blanks_enabled: - option.setFlags(option.flags() | QTextOption.ShowTabsAndSpaces) - else: - option.setFlags(option.flags() & ~QTextOption.ShowTabsAndSpaces) - self.document().setDefaultTextOption(option) - # Rehighlight to make the spaces less apparent. - self.rehighlight() - - def set_scrollpastend_enabled(self, state): - """ - Allow user to scroll past the end of the document to have the last - line on top of the screen - """ - self.scrollpastend_enabled = state - self.setCenterOnScroll(state) - self.setDocument(self.document()) - - def resizeEvent(self, event): - """Reimplemented Qt method to handle p resizing""" - TextEditBaseWidget.resizeEvent(self, event) - self.panels.resize() - - def showEvent(self, event): - """Overrides showEvent to update the viewport margins.""" - super(CodeEditor, self).showEvent(event) - self.panels.refresh() - - #-----Misc. - def _apply_highlighter_color_scheme(self): - """Apply color scheme from syntax highlighter to the editor""" - hl = self.highlighter - if hl is not None: - self.set_palette(background=hl.get_background_color(), - foreground=hl.get_foreground_color()) - self.currentline_color = hl.get_currentline_color() - self.currentcell_color = hl.get_currentcell_color() - self.occurrence_color = hl.get_occurrence_color() - self.ctrl_click_color = hl.get_ctrlclick_color() - self.sideareas_color = hl.get_sideareas_color() - self.comment_color = hl.get_comment_color() - self.normal_color = hl.get_foreground_color() - self.matched_p_color = hl.get_matched_p_color() - self.unmatched_p_color = hl.get_unmatched_p_color() - - self.edge_line.update_color() - self.indent_guides.update_color() - - self.sig_theme_colors_changed.emit( - {'occurrence': self.occurrence_color}) - - def apply_highlighter_settings(self, color_scheme=None): - """Apply syntax highlighter settings""" - if self.highlighter is not None: - # Updating highlighter settings (font and color scheme) - self.highlighter.setup_formats(self.font()) - if color_scheme is not None: - self.set_color_scheme(color_scheme) - else: - self._rehighlight_timer.start() - - def set_font(self, font, color_scheme=None): - """Set font""" - # Note: why using this method to set color scheme instead of - # 'set_color_scheme'? To avoid rehighlighting the document twice - # at startup. - if color_scheme is not None: - self.color_scheme = color_scheme - self.setFont(font) - self.panels.refresh() - self.apply_highlighter_settings(color_scheme) - - def set_color_scheme(self, color_scheme): - """Set color scheme for syntax highlighting""" - self.color_scheme = color_scheme - if self.highlighter is not None: - # this calls self.highlighter.rehighlight() - self.highlighter.set_color_scheme(color_scheme) - self._apply_highlighter_color_scheme() - if self.highlight_current_cell_enabled: - self.highlight_current_cell() - else: - self.unhighlight_current_cell() - if self.highlight_current_line_enabled: - self.highlight_current_line() - else: - self.unhighlight_current_line() - - def set_text(self, text): - """Set the text of the editor""" - self.setPlainText(text) - self.set_eol_chars(text=text) - - if (isinstance(self.highlighter, sh.PygmentsSH) - and not running_under_pytest()): - self.highlighter.make_charlist() - - def set_text_from_file(self, filename, language=None): - """Set the text of the editor from file *fname*""" - self.filename = filename - text, _enc = encoding.read(filename) - if language is None: - language = get_file_language(filename, text) - self.set_language(language, filename) - self.set_text(text) - - def append(self, text): - """Append text to the end of the text widget""" - cursor = self.textCursor() - cursor.movePosition(QTextCursor.End) - cursor.insertText(text) - - def adjust_indentation(self, line, indent_adjustment): - """Adjust indentation.""" - if indent_adjustment == 0 or line == "": - return line - using_spaces = self.indent_chars != '\t' - - if indent_adjustment > 0: - if using_spaces: - return ' ' * indent_adjustment + line - else: - return ( - self.indent_chars - * (indent_adjustment // self.tab_stop_width_spaces) - + line) - - max_indent = self.get_line_indentation(line) - indent_adjustment = min(max_indent, -indent_adjustment) - - indent_adjustment = (indent_adjustment if using_spaces else - indent_adjustment // self.tab_stop_width_spaces) - - return line[indent_adjustment:] - - @Slot() - def paste(self): - """ - Insert text or file/folder path copied from clipboard. - - Reimplement QPlainTextEdit's method to fix the following issue: - on Windows, pasted text has only 'LF' EOL chars even if the original - text has 'CRLF' EOL chars. - The function also changes the clipboard data if they are copied as - files/folders but does not change normal text data except if they are - multiple lines. Since we are changing clipboard data we cannot use - paste, which directly pastes from clipboard instead we use - insertPlainText and pass the formatted/changed text without modifying - clipboard content. - """ - clipboard = QApplication.clipboard() - text = to_text_string(clipboard.text()) - - if clipboard.mimeData().hasUrls(): - # Have copied file and folder urls pasted as text paths. - # See spyder-ide/spyder#8644 for details. - urls = clipboard.mimeData().urls() - if all([url.isLocalFile() for url in urls]): - if len(urls) > 1: - sep_chars = ',' + self.get_line_separator() - text = sep_chars.join('"' + url.toLocalFile(). - replace(osp.os.sep, '/') - + '"' for url in urls) - else: - # The `urls` list can be empty, so we need to check that - # before proceeding. - # Fixes spyder-ide/spyder#17521 - if urls: - text = urls[0].toLocalFile().replace(osp.os.sep, '/') - - eol_chars = self.get_line_separator() - if len(text.splitlines()) > 1: - text = eol_chars.join((text + eol_chars).splitlines()) - - # Align multiline text based on first line - cursor = self.textCursor() - cursor.beginEditBlock() - cursor.removeSelectedText() - cursor.setPosition(cursor.selectionStart()) - cursor.setPosition(cursor.block().position(), - QTextCursor.KeepAnchor) - preceding_text = cursor.selectedText() - first_line_selected, *remaining_lines = (text + eol_chars).splitlines() - first_line = preceding_text + first_line_selected - - first_line_adjustment = 0 - - # Dedent if automatic indentation makes code invalid - # Minimum indentation = max of current and paster indentation - if (self.is_python_like() and len(preceding_text.strip()) == 0 - and len(first_line.strip()) > 0): - # Correct indentation - desired_indent = self.find_indentation() - if desired_indent: - # minimum indentation is either the current indentation - # or the indentation of the paster text - desired_indent = max( - desired_indent, - self.get_line_indentation(first_line_selected), - self.get_line_indentation(preceding_text)) - first_line_adjustment = ( - desired_indent - self.get_line_indentation(first_line)) - # Only dedent, don't indent - first_line_adjustment = min(first_line_adjustment, 0) - # Only dedent, don't indent - first_line = self.adjust_indentation( - first_line, first_line_adjustment) - - # Fix indentation of multiline text based on first line - if len(remaining_lines) > 0 and len(first_line.strip()) > 0: - lines_adjustment = first_line_adjustment - lines_adjustment += CLIPBOARD_HELPER.remaining_lines_adjustment( - preceding_text) - - # Make sure the code is not flattened - indentations = [ - self.get_line_indentation(line) - for line in remaining_lines if line.strip() != ""] - if indentations: - max_dedent = min(indentations) - lines_adjustment = max(lines_adjustment, -max_dedent) - # Get new text - remaining_lines = [ - self.adjust_indentation(line, lines_adjustment) - for line in remaining_lines] - - text = eol_chars.join([first_line, *remaining_lines]) - - self.skip_rstrip = True - self.sig_will_paste_text.emit(text) - cursor.removeSelectedText() - cursor.insertText(text) - cursor.endEditBlock() - self.sig_text_was_inserted.emit() - - self.skip_rstrip = False - - def _save_clipboard_indentation(self): - """ - Save the indentation corresponding to the clipboard data. - - Must be called right after copying. - """ - cursor = self.textCursor() - cursor.setPosition(cursor.selectionStart()) - cursor.setPosition(cursor.block().position(), - QTextCursor.KeepAnchor) - preceding_text = cursor.selectedText() - CLIPBOARD_HELPER.save_indentation( - preceding_text, self.tab_stop_width_spaces) - - @Slot() - def cut(self): - """Reimplement cut to signal listeners about changes on the text.""" - has_selected_text = self.has_selected_text() - if not has_selected_text: - return - start, end = self.get_selection_start_end() - self.sig_will_remove_selection.emit(start, end) - TextEditBaseWidget.cut(self) - self._save_clipboard_indentation() - self.sig_text_was_inserted.emit() - - @Slot() - def copy(self): - """Reimplement copy to save indentation.""" - TextEditBaseWidget.copy(self) - self._save_clipboard_indentation() - - @Slot() - def undo(self): - """Reimplement undo to decrease text version number.""" - if self.document().isUndoAvailable(): - self.text_version -= 1 - self.skip_rstrip = True - self.is_undoing = True - TextEditBaseWidget.undo(self) - self.sig_undo.emit() - self.sig_text_was_inserted.emit() - self.is_undoing = False - self.skip_rstrip = False - - @Slot() - def redo(self): - """Reimplement redo to increase text version number.""" - if self.document().isRedoAvailable(): - self.text_version += 1 - self.skip_rstrip = True - self.is_redoing = True - TextEditBaseWidget.redo(self) - self.sig_redo.emit() - self.sig_text_was_inserted.emit() - self.is_redoing = False - self.skip_rstrip = False - - # ========================================================================= - # High-level editor features - # ========================================================================= - @Slot() - def center_cursor_on_next_focus(self): - """QPlainTextEdit's "centerCursor" requires the widget to be visible""" - self.centerCursor() - self.focus_in.disconnect(self.center_cursor_on_next_focus) - - def go_to_line(self, line, start_column=0, end_column=0, word=''): - """Go to line number *line* and eventually highlight it""" - self.text_helper.goto_line(line, column=start_column, - end_column=end_column, move=True, - word=word) - - def exec_gotolinedialog(self): - """Execute the GoToLineDialog dialog box""" - dlg = GoToLineDialog(self) - if dlg.exec_(): - self.go_to_line(dlg.get_line_number()) - - def hide_tooltip(self): - """ - Hide the tooltip widget. - - The tooltip widget is a special QLabel that looks like a tooltip, - this method is here so it can be hidden as necessary. For example, - when the user leaves the Linenumber area when hovering over lint - warnings and errors. - """ - self._timer_mouse_moving.stop() - self._last_hover_word = None - self.clear_extra_selections('code_analysis_highlight') - if self.tooltip_widget.isVisible(): - self.tooltip_widget.hide() - - def _set_completions_hint_idle(self): - self._completions_hint_idle = True - self.completion_widget.trigger_completion_hint() - - # --- Hint for completions - def show_hint_for_completion(self, word, documentation, at_point): - """Show hint for completion element.""" - if self.completions_hint and self._completions_hint_idle: - documentation = documentation.replace(u'\xa0', ' ') - completion_doc = {'name': word, - 'signature': documentation} - - if documentation and len(documentation) > 0: - self.show_hint( - documentation, - inspect_word=word, - at_point=at_point, - completion_doc=completion_doc, - max_lines=self._DEFAULT_MAX_LINES, - max_width=self._DEFAULT_COMPLETION_HINT_MAX_WIDTH) - self.tooltip_widget.move(at_point) - return - self.hide_tooltip() - - def update_decorations(self): - """Update decorations on the visible portion of the screen.""" - if self.underline_errors_enabled: - self.underline_errors() - - # This is required to update decorations whether there are or not - # underline errors in the visible portion of the screen. - # See spyder-ide/spyder#14268. - self.decorations.update() - - def show_code_analysis_results(self, line_number, block_data): - """Show warning/error messages.""" - # Diagnostic severity - icons = { - DiagnosticSeverity.ERROR: 'error', - DiagnosticSeverity.WARNING: 'warning', - DiagnosticSeverity.INFORMATION: 'information', - DiagnosticSeverity.HINT: 'hint', - } - - code_analysis = block_data.code_analysis - - # Size must be adapted from font - fm = self.fontMetrics() - size = fm.height() - template = ( - ' ' - '{} ({} {})' - ) - - msglist = [] - max_lines_msglist = 25 - sorted_code_analysis = sorted(code_analysis, key=lambda i: i[2]) - for src, code, sev, msg in sorted_code_analysis: - if src == 'pylint' and '[' in msg and ']' in msg: - # Remove extra redundant info from pylint messages - msg = msg.split(']')[-1] - - msg = msg.strip() - # Avoid messing TODO, FIXME - # Prevent error if msg only has one element - if len(msg) > 1: - msg = msg[0].upper() + msg[1:] - - # Get individual lines following paragraph format and handle - # symbols like '<' and '>' to not mess with br tags - msg = msg.replace('<', '<').replace('>', '>') - paragraphs = msg.splitlines() - new_paragraphs = [] - long_paragraphs = 0 - lines_per_message = 6 - for paragraph in paragraphs: - new_paragraph = textwrap.wrap( - paragraph, - width=self._DEFAULT_MAX_HINT_WIDTH) - if lines_per_message > 2: - if len(new_paragraph) > 1: - new_paragraph = '
'.join(new_paragraph[:2]) + '...' - long_paragraphs += 1 - lines_per_message -= 2 - else: - new_paragraph = '
'.join(new_paragraph) - lines_per_message -= 1 - new_paragraphs.append(new_paragraph) - - if len(new_paragraphs) > 1: - # Define max lines taking into account that in the same - # tooltip you can find multiple warnings and messages - # and each one can have multiple lines - if long_paragraphs != 0: - max_lines = 3 - max_lines_msglist -= max_lines * 2 - else: - max_lines = 5 - max_lines_msglist -= max_lines - msg = '
'.join(new_paragraphs[:max_lines]) + '
' - else: - msg = '
'.join(new_paragraphs) - - base_64 = ima.base64_from_icon(icons[sev], size, size) - if max_lines_msglist >= 0: - msglist.append(template.format(base_64, msg, src, - code, size=size)) - - if msglist: - self.show_tooltip( - title=_("Code analysis"), - text='\n'.join(msglist), - title_color=QStylePalette.COLOR_ACCENT_4, - at_line=line_number, - with_html_format=True - ) - self.highlight_line_warning(block_data) - - def highlight_line_warning(self, block_data): - """Highlight errors and warnings in this editor.""" - self.clear_extra_selections('code_analysis_highlight') - self.highlight_selection('code_analysis_highlight', - block_data._selection(), - background_color=block_data.color) - self.linenumberarea.update() - - def get_current_warnings(self): - """ - Get all warnings for the current editor and return - a list with the message and line number. - """ - block = self.document().firstBlock() - line_count = self.document().blockCount() - warnings = [] - while True: - data = block.userData() - if data and data.code_analysis: - for warning in data.code_analysis: - warnings.append([warning[-1], block.blockNumber() + 1]) - # See spyder-ide/spyder#9924 - if block.blockNumber() + 1 == line_count: - break - block = block.next() - return warnings - - def go_to_next_warning(self): - """ - Go to next code warning message and return new cursor position. - """ - block = self.textCursor().block() - line_count = self.document().blockCount() - for __ in range(line_count): - line_number = block.blockNumber() + 1 - if line_number < line_count: - block = block.next() - else: - block = self.document().firstBlock() - - data = block.userData() - if data and data.code_analysis: - line_number = block.blockNumber() + 1 - self.go_to_line(line_number) - self.show_code_analysis_results(line_number, data) - return self.get_position('cursor') - - def go_to_previous_warning(self): - """ - Go to previous code warning message and return new cursor position. - """ - block = self.textCursor().block() - line_count = self.document().blockCount() - for __ in range(line_count): - line_number = block.blockNumber() + 1 - if line_number > 1: - block = block.previous() - else: - block = self.document().lastBlock() - - data = block.userData() - if data and data.code_analysis: - line_number = block.blockNumber() + 1 - self.go_to_line(line_number) - self.show_code_analysis_results(line_number, data) - return self.get_position('cursor') - - def cell_list(self): - """Get the outline explorer data for all cells.""" - for oedata in self.outlineexplorer_data_list(): - if oedata.def_type == OED.CELL: - yield oedata - - def get_cell_code(self, cell): - """ - Get cell code for a given cell. - - If the cell doesn't exist, raises an exception - """ - selected_block = None - if is_string(cell): - for oedata in self.cell_list(): - if oedata.def_name == cell: - selected_block = oedata.block - break - else: - if cell == 0: - selected_block = self.document().firstBlock() - else: - cell_list = list(self.cell_list()) - if cell <= len(cell_list): - selected_block = cell_list[cell - 1].block - - if not selected_block: - raise RuntimeError("Cell {} not found.".format(repr(cell))) - - cursor = QTextCursor(selected_block) - cell_code, _ = self.get_cell_as_executable_code(cursor) - return cell_code - - def get_cell_count(self): - """Get number of cells in document.""" - return 1 + len(list(self.cell_list())) - - #------Tasks management - def go_to_next_todo(self): - """Go to next todo and return new cursor position""" - block = self.textCursor().block() - line_count = self.document().blockCount() - while True: - if block.blockNumber()+1 < line_count: - block = block.next() - else: - block = self.document().firstBlock() - data = block.userData() - if data and data.todo: - break - line_number = block.blockNumber()+1 - self.go_to_line(line_number) - self.show_tooltip( - title=_("To do"), - text=data.todo, - title_color=QStylePalette.COLOR_ACCENT_4, - at_line=line_number, - ) - - return self.get_position('cursor') - - def process_todo(self, todo_results): - """Process todo finder results""" - for data in self.blockuserdata_list(): - data.todo = '' - - for message, line_number in todo_results: - block = self.document().findBlockByNumber(line_number - 1) - data = block.userData() - if not data: - data = BlockUserData(self) - data.todo = message - block.setUserData(data) - self.sig_flags_changed.emit() - - #------Comments/Indentation - def add_prefix(self, prefix): - """Add prefix to current line or selected line(s)""" - cursor = self.textCursor() - if self.has_selected_text(): - # Add prefix to selected line(s) - start_pos, end_pos = cursor.selectionStart(), cursor.selectionEnd() - - # Let's see if selection begins at a block start - first_pos = min([start_pos, end_pos]) - first_cursor = self.textCursor() - first_cursor.setPosition(first_pos) - - cursor.beginEditBlock() - cursor.setPosition(end_pos) - # Check if end_pos is at the start of a block: if so, starting - # changes from the previous block - if cursor.atBlockStart(): - cursor.movePosition(QTextCursor.PreviousBlock) - if cursor.position() < start_pos: - cursor.setPosition(start_pos) - move_number = self.__spaces_for_prefix() - - while cursor.position() >= start_pos: - cursor.movePosition(QTextCursor.StartOfBlock) - line_text = to_text_string(cursor.block().text()) - if (self.get_character(cursor.position()) == ' ' - and '#' in prefix and not line_text.isspace() - or (not line_text.startswith(' ') - and line_text != '')): - cursor.movePosition(QTextCursor.Right, - QTextCursor.MoveAnchor, - move_number) - cursor.insertText(prefix) - elif '#' not in prefix: - cursor.insertText(prefix) - if cursor.blockNumber() == 0: - # Avoid infinite loop when indenting the very first line - break - cursor.movePosition(QTextCursor.PreviousBlock) - cursor.movePosition(QTextCursor.EndOfBlock) - cursor.endEditBlock() - else: - # Add prefix to current line - cursor.beginEditBlock() - cursor.movePosition(QTextCursor.StartOfBlock) - if self.get_character(cursor.position()) == ' ' and '#' in prefix: - cursor.movePosition(QTextCursor.NextWord) - cursor.insertText(prefix) - cursor.endEditBlock() - - def __spaces_for_prefix(self): - """Find the less indented level of text.""" - cursor = self.textCursor() - if self.has_selected_text(): - # Add prefix to selected line(s) - start_pos, end_pos = cursor.selectionStart(), cursor.selectionEnd() - - # Let's see if selection begins at a block start - first_pos = min([start_pos, end_pos]) - first_cursor = self.textCursor() - first_cursor.setPosition(first_pos) - - cursor.beginEditBlock() - cursor.setPosition(end_pos) - # Check if end_pos is at the start of a block: if so, starting - # changes from the previous block - if cursor.atBlockStart(): - cursor.movePosition(QTextCursor.PreviousBlock) - if cursor.position() < start_pos: - cursor.setPosition(start_pos) - - number_spaces = -1 - while cursor.position() >= start_pos: - cursor.movePosition(QTextCursor.StartOfBlock) - line_text = to_text_string(cursor.block().text()) - start_with_space = line_text.startswith(' ') - left_number_spaces = self.__number_of_spaces(line_text) - if not start_with_space: - left_number_spaces = 0 - if ((number_spaces == -1 - or number_spaces > left_number_spaces) - and not line_text.isspace() and line_text != ''): - number_spaces = left_number_spaces - if cursor.blockNumber() == 0: - # Avoid infinite loop when indenting the very first line - break - cursor.movePosition(QTextCursor.PreviousBlock) - cursor.movePosition(QTextCursor.EndOfBlock) - cursor.endEditBlock() - return number_spaces - - def remove_suffix(self, suffix): - """ - Remove suffix from current line (there should not be any selection) - """ - cursor = self.textCursor() - cursor.setPosition(cursor.position() - qstring_length(suffix), - QTextCursor.KeepAnchor) - if to_text_string(cursor.selectedText()) == suffix: - cursor.removeSelectedText() - - def remove_prefix(self, prefix): - """Remove prefix from current line or selected line(s)""" - cursor = self.textCursor() - if self.has_selected_text(): - # Remove prefix from selected line(s) - start_pos, end_pos = sorted([cursor.selectionStart(), - cursor.selectionEnd()]) - cursor.setPosition(start_pos) - if not cursor.atBlockStart(): - cursor.movePosition(QTextCursor.StartOfBlock) - start_pos = cursor.position() - cursor.beginEditBlock() - cursor.setPosition(end_pos) - # Check if end_pos is at the start of a block: if so, starting - # changes from the previous block - if cursor.atBlockStart(): - cursor.movePosition(QTextCursor.PreviousBlock) - if cursor.position() < start_pos: - cursor.setPosition(start_pos) - - cursor.movePosition(QTextCursor.StartOfBlock) - old_pos = None - while cursor.position() >= start_pos: - new_pos = cursor.position() - if old_pos == new_pos: - break - else: - old_pos = new_pos - line_text = to_text_string(cursor.block().text()) - self.__remove_prefix(prefix, cursor, line_text) - cursor.movePosition(QTextCursor.PreviousBlock) - cursor.endEditBlock() - else: - # Remove prefix from current line - cursor.movePosition(QTextCursor.StartOfBlock) - line_text = to_text_string(cursor.block().text()) - self.__remove_prefix(prefix, cursor, line_text) - - def __remove_prefix(self, prefix, cursor, line_text): - """Handle the removal of the prefix for a single line.""" - start_with_space = line_text.startswith(' ') - if start_with_space: - left_spaces = self.__even_number_of_spaces(line_text) - else: - left_spaces = False - if start_with_space: - right_number_spaces = self.__number_of_spaces(line_text, group=1) - else: - right_number_spaces = self.__number_of_spaces(line_text) - # Handle prefix remove for comments with spaces - if (prefix.strip() and line_text.lstrip().startswith(prefix + ' ') - or line_text.startswith(prefix + ' ') and '#' in prefix): - cursor.movePosition(QTextCursor.Right, - QTextCursor.MoveAnchor, - line_text.find(prefix)) - if (right_number_spaces == 1 - and (left_spaces or not start_with_space) - or (not start_with_space and right_number_spaces % 2 != 0) - or (left_spaces and right_number_spaces % 2 != 0)): - # Handle inserted '# ' with the count of the number of spaces - # at the right and left of the prefix. - cursor.movePosition(QTextCursor.Right, - QTextCursor.KeepAnchor, len(prefix + ' ')) - else: - # Handle manual insertion of '#' - cursor.movePosition(QTextCursor.Right, - QTextCursor.KeepAnchor, len(prefix)) - cursor.removeSelectedText() - # Check for prefix without space - elif (prefix.strip() and line_text.lstrip().startswith(prefix) - or line_text.startswith(prefix)): - cursor.movePosition(QTextCursor.Right, - QTextCursor.MoveAnchor, - line_text.find(prefix)) - cursor.movePosition(QTextCursor.Right, - QTextCursor.KeepAnchor, len(prefix)) - cursor.removeSelectedText() - - def __even_number_of_spaces(self, line_text, group=0): - """ - Get if there is a correct indentation from a group of spaces of a line. - """ - spaces = re.findall(r'\s+', line_text) - if len(spaces) - 1 >= group: - return len(spaces[group]) % len(self.indent_chars) == 0 - - def __number_of_spaces(self, line_text, group=0): - """Get the number of spaces from a group of spaces in a line.""" - spaces = re.findall(r'\s+', line_text) - if len(spaces) - 1 >= group: - return len(spaces[group]) - - def __get_brackets(self, line_text, closing_brackets=[]): - """ - Return unmatched opening brackets and left-over closing brackets. - - (str, []) -> ([(pos, bracket)], [bracket], comment_pos) - - Iterate through line_text to find unmatched brackets. - - Returns three objects as a tuple: - 1) bracket_stack: - a list of tuples of pos and char of each unmatched opening bracket - 2) closing brackets: - this line's unmatched closing brackets + arg closing_brackets. - If this line ad no closing brackets, arg closing_brackets might - be matched with previously unmatched opening brackets in this line. - 3) Pos at which a # comment begins. -1 if it doesn't.' - """ - # Remove inline comment and check brackets - bracket_stack = [] # list containing this lines unmatched opening - # same deal, for closing though. Ignore if bracket stack not empty, - # since they are mismatched in that case. - bracket_unmatched_closing = [] - comment_pos = -1 - deactivate = None - escaped = False - pos, c = None, None - for pos, c in enumerate(line_text): - # Handle '\' inside strings - if escaped: - escaped = False - # Handle strings - elif deactivate: - if c == deactivate: - deactivate = None - elif c == "\\": - escaped = True - elif c in ["'", '"']: - deactivate = c - # Handle comments - elif c == "#": - comment_pos = pos - break - # Handle brackets - elif c in ('(', '[', '{'): - bracket_stack.append((pos, c)) - elif c in (')', ']', '}'): - if bracket_stack and bracket_stack[-1][1] == \ - {')': '(', ']': '[', '}': '{'}[c]: - bracket_stack.pop() - else: - bracket_unmatched_closing.append(c) - del pos, deactivate, escaped - # If no closing brackets are left over from this line, - # check the ones from previous iterations' prevlines - if not bracket_unmatched_closing: - for c in list(closing_brackets): - if bracket_stack and bracket_stack[-1][1] == \ - {')': '(', ']': '[', '}': '{'}[c]: - bracket_stack.pop() - closing_brackets.remove(c) - else: - break - del c - closing_brackets = bracket_unmatched_closing + closing_brackets - return (bracket_stack, closing_brackets, comment_pos) - - def fix_indent(self, *args, **kwargs): - """Indent line according to the preferences""" - if self.is_python_like(): - ret = self.fix_indent_smart(*args, **kwargs) - else: - ret = self.simple_indentation(*args, **kwargs) - return ret - - def simple_indentation(self, forward=True, **kwargs): - """ - Simply preserve the indentation-level of the previous line. - """ - cursor = self.textCursor() - block_nb = cursor.blockNumber() - prev_block = self.document().findBlockByNumber(block_nb - 1) - prevline = to_text_string(prev_block.text()) - - indentation = re.match(r"\s*", prevline).group() - # Unident - if not forward: - indentation = indentation[len(self.indent_chars):] - - cursor.insertText(indentation) - return False # simple indentation don't fix indentation - - def find_indentation(self, forward=True, comment_or_string=False, - cur_indent=None): - """ - Find indentation (Python only, no text selection) - - forward=True: fix indent only if text is not enough indented - (otherwise force indent) - forward=False: fix indent only if text is too much indented - (otherwise force unindent) - - comment_or_string: Do not adjust indent level for - unmatched opening brackets and keywords - - max_blank_lines: maximum number of blank lines to search before giving - up - - cur_indent: current indent. This is the indent before we started - processing. E.g. when returning, indent before rstrip. - - Returns the indentation for the current line - - Assumes self.is_python_like() to return True - """ - cursor = self.textCursor() - block_nb = cursor.blockNumber() - # find the line that contains our scope - line_in_block = False - visual_indent = False - add_indent = 0 # How many levels of indent to add - prevline = None - prevtext = "" - empty_lines = True - - closing_brackets = [] - for prevline in range(block_nb - 1, -1, -1): - cursor.movePosition(QTextCursor.PreviousBlock) - prevtext = to_text_string(cursor.block().text()).rstrip() - - bracket_stack, closing_brackets, comment_pos = self.__get_brackets( - prevtext, closing_brackets) - - if not prevtext: - continue - - if prevtext.endswith((':', '\\')): - # Presume a block was started - line_in_block = True # add one level of indent to correct_indent - # Does this variable actually do *anything* of relevance? - # comment_or_string = True - - if bracket_stack or not closing_brackets: - break - - if prevtext.strip() != '': - empty_lines = False - - if empty_lines and prevline is not None and prevline < block_nb - 2: - # The previous line is too far, ignore - prevtext = '' - prevline = block_nb - 2 - line_in_block = False - - # splits of prevtext happen a few times. Let's just do it once - words = re.split(r'[\s\(\[\{\}\]\)]', prevtext.lstrip()) - - if line_in_block: - add_indent += 1 - - if prevtext and not comment_or_string: - if bracket_stack: - # Hanging indent - if prevtext.endswith(('(', '[', '{')): - add_indent += 1 - if words[0] in ('class', 'def', 'elif', 'except', 'for', - 'if', 'while', 'with'): - add_indent += 1 - elif not ( # I'm not sure this block should exist here - ( - self.tab_stop_width_spaces - if self.indent_chars == '\t' else - len(self.indent_chars) - ) * 2 < len(prevtext)): - visual_indent = True - else: - # There's stuff after unmatched opening brackets - visual_indent = True - elif (words[-1] in ('continue', 'break', 'pass',) - or words[0] == "return" and not line_in_block - ): - add_indent -= 1 - - if prevline: - prevline_indent = self.get_block_indentation(prevline) - else: - prevline_indent = 0 - - if visual_indent: # can only be true if bracket_stack - correct_indent = bracket_stack[-1][0] + 1 - elif add_indent: - # Indent - if self.indent_chars == '\t': - correct_indent = prevline_indent + self.tab_stop_width_spaces * add_indent - else: - correct_indent = prevline_indent + len(self.indent_chars) * add_indent - else: - correct_indent = prevline_indent - - # TODO untangle this block - if prevline and not bracket_stack and not prevtext.endswith(':'): - if forward: - # Keep indentation of previous line - ref_line = block_nb - 1 - else: - # Find indentation context - ref_line = prevline - if cur_indent is None: - cur_indent = self.get_block_indentation(ref_line) - is_blank = not self.get_text_line(ref_line).strip() - trailing_text = self.get_text_line(block_nb).strip() - # If brackets are matched and no block gets opened - # Match the above line's indent and nudge to the next multiple of 4 - - if cur_indent < prevline_indent and (trailing_text or is_blank): - # if line directly above is blank or there is text after cursor - # Ceiling division - correct_indent = -(-cur_indent // len(self.indent_chars)) * \ - len(self.indent_chars) - return correct_indent - - def fix_indent_smart(self, forward=True, comment_or_string=False, - cur_indent=None): - """ - Fix indentation (Python only, no text selection) - - forward=True: fix indent only if text is not enough indented - (otherwise force indent) - forward=False: fix indent only if text is too much indented - (otherwise force unindent) - - comment_or_string: Do not adjust indent level for - unmatched opening brackets and keywords - - max_blank_lines: maximum number of blank lines to search before giving - up - - cur_indent: current indent. This is the indent before we started - processing. E.g. when returning, indent before rstrip. - - Returns True if indent needed to be fixed - - Assumes self.is_python_like() to return True - """ - cursor = self.textCursor() - block_nb = cursor.blockNumber() - indent = self.get_block_indentation(block_nb) - - correct_indent = self.find_indentation( - forward, comment_or_string, cur_indent) - - if correct_indent >= 0 and not ( - indent == correct_indent or - forward and indent > correct_indent or - not forward and indent < correct_indent - ): - # Insert the determined indent - cursor = self.textCursor() - cursor.movePosition(QTextCursor.StartOfBlock) - if self.indent_chars == '\t': - indent = indent // self.tab_stop_width_spaces - cursor.setPosition(cursor.position()+indent, QTextCursor.KeepAnchor) - cursor.removeSelectedText() - if self.indent_chars == '\t': - indent_text = ( - '\t' * (correct_indent // self.tab_stop_width_spaces) + - ' ' * (correct_indent % self.tab_stop_width_spaces) - ) - else: - indent_text = ' '*correct_indent - cursor.insertText(indent_text) - return True - return False - - @Slot() - def clear_all_output(self): - """Removes all output in the ipynb format (Json only)""" - try: - nb = nbformat.reads(self.toPlainText(), as_version=4) - if nb.cells: - for cell in nb.cells: - if 'outputs' in cell: - cell['outputs'] = [] - if 'prompt_number' in cell: - cell['prompt_number'] = None - # We do the following rather than using self.setPlainText - # to benefit from QTextEdit's undo/redo feature. - self.selectAll() - self.skip_rstrip = True - self.insertPlainText(nbformat.writes(nb)) - self.skip_rstrip = False - except Exception as e: - QMessageBox.critical(self, _('Removal error'), - _("It was not possible to remove outputs from " - "this notebook. The error is:\n\n") + \ - to_text_string(e)) - return - - @Slot() - def convert_notebook(self): - """Convert an IPython notebook to a Python script in editor""" - try: - nb = nbformat.reads(self.toPlainText(), as_version=4) - script = nbexporter().from_notebook_node(nb)[0] - except Exception as e: - QMessageBox.critical(self, _('Conversion error'), - _("It was not possible to convert this " - "notebook. The error is:\n\n") + \ - to_text_string(e)) - return - self.sig_new_file.emit(script) - - def indent(self, force=False): - """ - Indent current line or selection - - force=True: indent even if cursor is not a the beginning of the line - """ - leading_text = self.get_text('sol', 'cursor') - if self.has_selected_text(): - self.add_prefix(self.indent_chars) - elif (force or not leading_text.strip() or - (self.tab_indents and self.tab_mode)): - if self.is_python_like(): - if not self.fix_indent(forward=True): - self.add_prefix(self.indent_chars) - else: - self.add_prefix(self.indent_chars) - else: - if len(self.indent_chars) > 1: - length = len(self.indent_chars) - self.insert_text(" "*(length-(len(leading_text) % length))) - else: - self.insert_text(self.indent_chars) - - def indent_or_replace(self): - """Indent or replace by 4 spaces depending on selection and tab mode""" - if (self.tab_indents and self.tab_mode) or not self.has_selected_text(): - self.indent() - else: - cursor = self.textCursor() - if (self.get_selected_text() == - to_text_string(cursor.block().text())): - self.indent() - else: - cursor1 = self.textCursor() - cursor1.setPosition(cursor.selectionStart()) - cursor2 = self.textCursor() - cursor2.setPosition(cursor.selectionEnd()) - if cursor1.blockNumber() != cursor2.blockNumber(): - self.indent() - else: - self.replace(self.indent_chars) - - def unindent(self, force=False): - """ - Unindent current line or selection - - force=True: unindent even if cursor is not a the beginning of the line - """ - if self.has_selected_text(): - if self.indent_chars == "\t": - # Tabs, remove one tab - self.remove_prefix(self.indent_chars) - else: - # Spaces - space_count = len(self.indent_chars) - leading_spaces = self.__spaces_for_prefix() - remainder = leading_spaces % space_count - if remainder: - # Get block on "space multiple grid". - # See spyder-ide/spyder#5734. - self.remove_prefix(" "*remainder) - else: - # Unindent one space multiple - self.remove_prefix(self.indent_chars) - else: - leading_text = self.get_text('sol', 'cursor') - if (force or not leading_text.strip() or - (self.tab_indents and self.tab_mode)): - if self.is_python_like(): - if not self.fix_indent(forward=False): - self.remove_prefix(self.indent_chars) - elif leading_text.endswith('\t'): - self.remove_prefix('\t') - else: - self.remove_prefix(self.indent_chars) - - @Slot() - def toggle_comment(self): - """Toggle comment on current line or selection""" - cursor = self.textCursor() - start_pos, end_pos = sorted([cursor.selectionStart(), - cursor.selectionEnd()]) - cursor.setPosition(end_pos) - last_line = cursor.block().blockNumber() - if cursor.atBlockStart() and start_pos != end_pos: - last_line -= 1 - cursor.setPosition(start_pos) - first_line = cursor.block().blockNumber() - # If the selection contains only commented lines and surrounding - # whitespace, uncomment. Otherwise, comment. - is_comment_or_whitespace = True - at_least_one_comment = False - for _line_nb in range(first_line, last_line+1): - text = to_text_string(cursor.block().text()).lstrip() - is_comment = text.startswith(self.comment_string) - is_whitespace = (text == '') - is_comment_or_whitespace *= (is_comment or is_whitespace) - if is_comment: - at_least_one_comment = True - cursor.movePosition(QTextCursor.NextBlock) - if is_comment_or_whitespace and at_least_one_comment: - self.uncomment() - else: - self.comment() - - def is_comment(self, block): - """Detect inline comments. - - Return True if the block is an inline comment. - """ - if block is None: - return False - text = to_text_string(block.text()).lstrip() - return text.startswith(self.comment_string) - - def comment(self): - """Comment current line or selection.""" - self.add_prefix(self.comment_string + ' ') - - def uncomment(self): - """Uncomment current line or selection.""" - blockcomment = self.unblockcomment() - if not blockcomment: - self.remove_prefix(self.comment_string) - - def __blockcomment_bar(self, compatibility=False): - """Handle versions of blockcomment bar for backwards compatibility.""" - # Blockcomment bar in Spyder version >= 4 - blockcomment_bar = self.comment_string + ' ' + '=' * ( - 79 - len(self.comment_string + ' ')) - if compatibility: - # Blockcomment bar in Spyder version < 4 - blockcomment_bar = self.comment_string + '=' * ( - 79 - len(self.comment_string)) - return blockcomment_bar - - def transform_to_uppercase(self): - """Change to uppercase current line or selection.""" - cursor = self.textCursor() - prev_pos = cursor.position() - selected_text = to_text_string(cursor.selectedText()) - - if len(selected_text) == 0: - prev_pos = cursor.position() - cursor.select(QTextCursor.WordUnderCursor) - selected_text = to_text_string(cursor.selectedText()) - - s = selected_text.upper() - cursor.insertText(s) - self.set_cursor_position(prev_pos) - - def transform_to_lowercase(self): - """Change to lowercase current line or selection.""" - cursor = self.textCursor() - prev_pos = cursor.position() - selected_text = to_text_string(cursor.selectedText()) - - if len(selected_text) == 0: - prev_pos = cursor.position() - cursor.select(QTextCursor.WordUnderCursor) - selected_text = to_text_string(cursor.selectedText()) - - s = selected_text.lower() - cursor.insertText(s) - self.set_cursor_position(prev_pos) - - def blockcomment(self): - """Block comment current line or selection.""" - comline = self.__blockcomment_bar() + self.get_line_separator() - cursor = self.textCursor() - if self.has_selected_text(): - self.extend_selection_to_complete_lines() - start_pos, end_pos = cursor.selectionStart(), cursor.selectionEnd() - else: - start_pos = end_pos = cursor.position() - cursor.beginEditBlock() - cursor.setPosition(start_pos) - cursor.movePosition(QTextCursor.StartOfBlock) - while cursor.position() <= end_pos: - cursor.insertText(self.comment_string + " ") - cursor.movePosition(QTextCursor.EndOfBlock) - if cursor.atEnd(): - break - cursor.movePosition(QTextCursor.NextBlock) - end_pos += len(self.comment_string + " ") - cursor.setPosition(end_pos) - cursor.movePosition(QTextCursor.EndOfBlock) - if cursor.atEnd(): - cursor.insertText(self.get_line_separator()) - else: - cursor.movePosition(QTextCursor.NextBlock) - cursor.insertText(comline) - cursor.setPosition(start_pos) - cursor.movePosition(QTextCursor.StartOfBlock) - cursor.insertText(comline) - cursor.endEditBlock() - - def unblockcomment(self): - """Un-block comment current line or selection.""" - # Needed for backward compatibility with Spyder previous blockcomments. - # See spyder-ide/spyder#2845. - unblockcomment = self.__unblockcomment() - if not unblockcomment: - unblockcomment = self.__unblockcomment(compatibility=True) - else: - return unblockcomment - - def __unblockcomment(self, compatibility=False): - """Un-block comment current line or selection helper.""" - def __is_comment_bar(cursor): - return to_text_string(cursor.block().text() - ).startswith( - self.__blockcomment_bar(compatibility=compatibility)) - # Finding first comment bar - cursor1 = self.textCursor() - if __is_comment_bar(cursor1): - return - while not __is_comment_bar(cursor1): - cursor1.movePosition(QTextCursor.PreviousBlock) - if cursor1.blockNumber() == 0: - break - if not __is_comment_bar(cursor1): - return False - - def __in_block_comment(cursor): - cs = self.comment_string - return to_text_string(cursor.block().text()).startswith(cs) - # Finding second comment bar - cursor2 = QTextCursor(cursor1) - cursor2.movePosition(QTextCursor.NextBlock) - while not __is_comment_bar(cursor2) and __in_block_comment(cursor2): - cursor2.movePosition(QTextCursor.NextBlock) - if cursor2.block() == self.document().lastBlock(): - break - if not __is_comment_bar(cursor2): - return False - # Removing block comment - cursor3 = self.textCursor() - cursor3.beginEditBlock() - cursor3.setPosition(cursor1.position()) - cursor3.movePosition(QTextCursor.NextBlock) - while cursor3.position() < cursor2.position(): - cursor3.movePosition(QTextCursor.NextCharacter, - QTextCursor.KeepAnchor) - if not cursor3.atBlockEnd(): - # standard commenting inserts '# ' but a trailing space on an - # empty line might be stripped. - if not compatibility: - cursor3.movePosition(QTextCursor.NextCharacter, - QTextCursor.KeepAnchor) - cursor3.removeSelectedText() - cursor3.movePosition(QTextCursor.NextBlock) - for cursor in (cursor2, cursor1): - cursor3.setPosition(cursor.position()) - cursor3.select(QTextCursor.BlockUnderCursor) - cursor3.removeSelectedText() - cursor3.endEditBlock() - return True - - #------Kill ring handlers - # Taken from Jupyter's QtConsole - # Copyright (c) 2001-2015, IPython Development Team - # Copyright (c) 2015-, Jupyter Development Team - def kill_line_end(self): - """Kill the text on the current line from the cursor forward""" - cursor = self.textCursor() - cursor.clearSelection() - cursor.movePosition(QTextCursor.EndOfLine, QTextCursor.KeepAnchor) - if not cursor.hasSelection(): - # Line deletion - cursor.movePosition(QTextCursor.NextBlock, - QTextCursor.KeepAnchor) - self._kill_ring.kill_cursor(cursor) - self.setTextCursor(cursor) - - def kill_line_start(self): - """Kill the text on the current line from the cursor backward""" - cursor = self.textCursor() - cursor.clearSelection() - cursor.movePosition(QTextCursor.StartOfBlock, - QTextCursor.KeepAnchor) - self._kill_ring.kill_cursor(cursor) - self.setTextCursor(cursor) - - def _get_word_start_cursor(self, position): - """Find the start of the word to the left of the given position. If a - sequence of non-word characters precedes the first word, skip over - them. (This emulates the behavior of bash, emacs, etc.) - """ - document = self.document() - position -= 1 - while (position and not - self.is_letter_or_number(document.characterAt(position))): - position -= 1 - while position and self.is_letter_or_number( - document.characterAt(position)): - position -= 1 - cursor = self.textCursor() - cursor.setPosition(self.next_cursor_position()) - return cursor - - def _get_word_end_cursor(self, position): - """Find the end of the word to the right of the given position. If a - sequence of non-word characters precedes the first word, skip over - them. (This emulates the behavior of bash, emacs, etc.) - """ - document = self.document() - cursor = self.textCursor() - position = cursor.position() - cursor.movePosition(QTextCursor.End) - end = cursor.position() - while (position < end and - not self.is_letter_or_number(document.characterAt(position))): - position = self.next_cursor_position(position) - while (position < end and - self.is_letter_or_number(document.characterAt(position))): - position = self.next_cursor_position(position) - cursor.setPosition(position) - return cursor - - def kill_prev_word(self): - """Kill the previous word""" - position = self.textCursor().position() - cursor = self._get_word_start_cursor(position) - cursor.setPosition(position, QTextCursor.KeepAnchor) - self._kill_ring.kill_cursor(cursor) - self.setTextCursor(cursor) - - def kill_next_word(self): - """Kill the next word""" - position = self.textCursor().position() - cursor = self._get_word_end_cursor(position) - cursor.setPosition(position, QTextCursor.KeepAnchor) - self._kill_ring.kill_cursor(cursor) - self.setTextCursor(cursor) - - #------Autoinsertion of quotes/colons - def __get_current_color(self, cursor=None): - """Get the syntax highlighting color for the current cursor position""" - if cursor is None: - cursor = self.textCursor() - - block = cursor.block() - pos = cursor.position() - block.position() # relative pos within block - layout = block.layout() - block_formats = layout.additionalFormats() - - if block_formats: - # To easily grab current format for autoinsert_colons - if cursor.atBlockEnd(): - current_format = block_formats[-1].format - else: - current_format = None - for fmt in block_formats: - if (pos >= fmt.start) and (pos < fmt.start + fmt.length): - current_format = fmt.format - if current_format is None: - return None - color = current_format.foreground().color().name() - return color - else: - return None - - def in_comment_or_string(self, cursor=None, position=None): - """Is the cursor or position inside or next to a comment or string? - - If *cursor* is None, *position* is used instead. If *position* is also - None, then the current cursor position is used. - """ - if self.highlighter: - if cursor is None: - cursor = self.textCursor() - if position: - cursor.setPosition(position) - current_color = self.__get_current_color(cursor=cursor) - - comment_color = self.highlighter.get_color_name('comment') - string_color = self.highlighter.get_color_name('string') - if (current_color == comment_color) or (current_color == string_color): - return True - else: - return False - else: - return False - - def __colon_keyword(self, text): - stmt_kws = ['def', 'for', 'if', 'while', 'with', 'class', 'elif', - 'except'] - whole_kws = ['else', 'try', 'except', 'finally'] - text = text.lstrip() - words = text.split() - if any([text == wk for wk in whole_kws]): - return True - elif len(words) < 2: - return False - elif any([words[0] == sk for sk in stmt_kws]): - return True - else: - return False - - def __forbidden_colon_end_char(self, text): - end_chars = [':', '\\', '[', '{', '(', ','] - text = text.rstrip() - if any([text.endswith(c) for c in end_chars]): - return True - else: - return False - - def __has_colon_not_in_brackets(self, text): - """ - Return whether a string has a colon which is not between brackets. - This function returns True if the given string has a colon which is - not between a pair of (round, square or curly) brackets. It assumes - that the brackets in the string are balanced. - """ - bracket_ext = self.editor_extensions.get(CloseBracketsExtension) - for pos, char in enumerate(text): - if (char == ':' and - not bracket_ext.unmatched_brackets_in_line(text[:pos])): - return True - return False - - def __has_unmatched_opening_bracket(self): - """ - Checks if there are any unmatched opening brackets before the current - cursor position. - """ - position = self.textCursor().position() - for brace in [']', ')', '}']: - match = self.find_brace_match(position, brace, forward=False) - if match is not None: - return True - return False - - def autoinsert_colons(self): - """Decide if we want to autoinsert colons""" - bracket_ext = self.editor_extensions.get(CloseBracketsExtension) - self.completion_widget.hide() - line_text = self.get_text('sol', 'cursor') - if not self.textCursor().atBlockEnd(): - return False - elif self.in_comment_or_string(): - return False - elif not self.__colon_keyword(line_text): - return False - elif self.__forbidden_colon_end_char(line_text): - return False - elif bracket_ext.unmatched_brackets_in_line(line_text): - return False - elif self.__has_colon_not_in_brackets(line_text): - return False - elif self.__has_unmatched_opening_bracket(): - return False - else: - return True - - def next_char(self): - cursor = self.textCursor() - cursor.movePosition(QTextCursor.NextCharacter, - QTextCursor.KeepAnchor) - next_char = to_text_string(cursor.selectedText()) - return next_char - - def in_comment(self, cursor=None, position=None): - """Returns True if the given position is inside a comment. - - Parameters - ---------- - cursor : QTextCursor, optional - The position to check. - position : int, optional - The position to check if *cursor* is None. This parameter - is ignored when *cursor* is not None. - - If both *cursor* and *position* are none, then the position returned - by self.textCursor() is used instead. - """ - if self.highlighter: - if cursor is None: - cursor = self.textCursor() - if position is not None: - cursor.setPosition(position) - current_color = self.__get_current_color(cursor) - comment_color = self.highlighter.get_color_name('comment') - return (current_color == comment_color) - else: - return False - - def in_string(self, cursor=None, position=None): - """Returns True if the given position is inside a string. - - Parameters - ---------- - cursor : QTextCursor, optional - The position to check. - position : int, optional - The position to check if *cursor* is None. This parameter - is ignored when *cursor* is not None. - - If both *cursor* and *position* are none, then the position returned - by self.textCursor() is used instead. - """ - if self.highlighter: - if cursor is None: - cursor = self.textCursor() - if position is not None: - cursor.setPosition(position) - current_color = self.__get_current_color(cursor) - string_color = self.highlighter.get_color_name('string') - return (current_color == string_color) - else: - return False - - # ------ Qt Event handlers - def setup_context_menu(self): - """Setup context menu""" - self.undo_action = create_action( - self, _("Undo"), icon=ima.icon('undo'), - shortcut=CONF.get_shortcut('editor', 'undo'), triggered=self.undo) - self.redo_action = create_action( - self, _("Redo"), icon=ima.icon('redo'), - shortcut=CONF.get_shortcut('editor', 'redo'), triggered=self.redo) - self.cut_action = create_action( - self, _("Cut"), icon=ima.icon('editcut'), - shortcut=CONF.get_shortcut('editor', 'cut'), triggered=self.cut) - self.copy_action = create_action( - self, _("Copy"), icon=ima.icon('editcopy'), - shortcut=CONF.get_shortcut('editor', 'copy'), triggered=self.copy) - self.paste_action = create_action( - self, _("Paste"), icon=ima.icon('editpaste'), - shortcut=CONF.get_shortcut('editor', 'paste'), - triggered=self.paste) - selectall_action = create_action( - self, _("Select All"), icon=ima.icon('selectall'), - shortcut=CONF.get_shortcut('editor', 'select all'), - triggered=self.selectAll) - toggle_comment_action = create_action( - self, _("Comment")+"/"+_("Uncomment"), icon=ima.icon('comment'), - shortcut=CONF.get_shortcut('editor', 'toggle comment'), - triggered=self.toggle_comment) - self.clear_all_output_action = create_action( - self, _("Clear all ouput"), icon=ima.icon('ipython_console'), - triggered=self.clear_all_output) - self.ipynb_convert_action = create_action( - self, _("Convert to Python file"), icon=ima.icon('python'), - triggered=self.convert_notebook) - self.gotodef_action = create_action( - self, _("Go to definition"), - shortcut=CONF.get_shortcut('editor', 'go to definition'), - triggered=self.go_to_definition_from_cursor) - - self.inspect_current_object_action = create_action( - self, _("Inspect current object"), - icon=ima.icon('MessageBoxInformation'), - shortcut=CONF.get_shortcut('editor', 'inspect current object'), - triggered=self.sig_show_object_info.emit) - - # Run actions - self.run_cell_action = create_action( - self, _("Run cell"), icon=ima.icon('run_cell'), - shortcut=CONF.get_shortcut('editor', 'run cell'), - triggered=self.sig_run_cell) - self.run_cell_and_advance_action = create_action( - self, _("Run cell and advance"), icon=ima.icon('run_cell_advance'), - shortcut=CONF.get_shortcut('editor', 'run cell and advance'), - triggered=self.sig_run_cell_and_advance) - self.re_run_last_cell_action = create_action( - self, _("Re-run last cell"), - shortcut=CONF.get_shortcut('editor', 're-run last cell'), - triggered=self.sig_re_run_last_cell) - self.run_selection_action = create_action( - self, _("Run &selection or current line"), - icon=ima.icon('run_selection'), - shortcut=CONF.get_shortcut('editor', 'run selection'), - triggered=self.sig_run_selection) - self.run_to_line_action = create_action( - self, _("Run to current line"), - shortcut=CONF.get_shortcut('editor', 'run to line'), - triggered=self.sig_run_to_line) - self.run_from_line_action = create_action( - self, _("Run from current line"), - shortcut=CONF.get_shortcut('editor', 'run from line'), - triggered=self.sig_run_from_line) - self.debug_cell_action = create_action( - self, _("Debug cell"), icon=ima.icon('debug_cell'), - shortcut=CONF.get_shortcut('editor', 'debug cell'), - triggered=self.sig_debug_cell) - - # Zoom actions - zoom_in_action = create_action( - self, _("Zoom in"), icon=ima.icon('zoom_in'), - shortcut=QKeySequence(QKeySequence.ZoomIn), - triggered=self.zoom_in) - zoom_out_action = create_action( - self, _("Zoom out"), icon=ima.icon('zoom_out'), - shortcut=QKeySequence(QKeySequence.ZoomOut), - triggered=self.zoom_out) - zoom_reset_action = create_action( - self, _("Zoom reset"), shortcut=QKeySequence("Ctrl+0"), - triggered=self.zoom_reset) - - # Docstring - writer = self.writer_docstring - self.docstring_action = create_action( - self, _("Generate docstring"), - shortcut=CONF.get_shortcut('editor', 'docstring'), - triggered=writer.write_docstring_at_first_line_of_function) - - # Document formatting - formatter = CONF.get( - 'completions', - ('provider_configuration', 'lsp', 'values', 'formatting'), - '' - ) - self.format_action = create_action( - self, - _('Format file or selection with {0}').format( - formatter.capitalize()), - shortcut=CONF.get_shortcut('editor', 'autoformatting'), - triggered=self.format_document_or_range) - - self.format_action.setEnabled(False) - - # Build menu - self.menu = QMenu(self) - actions_1 = [self.run_cell_action, self.run_cell_and_advance_action, - self.re_run_last_cell_action, self.run_selection_action, - self.run_to_line_action, self.run_from_line_action, - self.gotodef_action, self.inspect_current_object_action, - None, self.undo_action, - self.redo_action, None, self.cut_action, - self.copy_action, self.paste_action, selectall_action] - actions_2 = [None, zoom_in_action, zoom_out_action, zoom_reset_action, - None, toggle_comment_action, self.docstring_action, - self.format_action] - if nbformat is not None: - nb_actions = [self.clear_all_output_action, - self.ipynb_convert_action, None] - actions = actions_1 + nb_actions + actions_2 - add_actions(self.menu, actions) - else: - actions = actions_1 + actions_2 - add_actions(self.menu, actions) - - # Read-only context-menu - self.readonly_menu = QMenu(self) - add_actions(self.readonly_menu, - (self.copy_action, None, selectall_action, - self.gotodef_action)) - - def keyReleaseEvent(self, event): - """Override Qt method.""" - self.sig_key_released.emit(event) - key = event.key() - direction_keys = {Qt.Key_Up, Qt.Key_Left, Qt.Key_Right, Qt.Key_Down} - if key in direction_keys: - self.request_cursor_event() - - # Update decorations after releasing these keys because they don't - # trigger the emission of the valueChanged signal in - # verticalScrollBar. - # See https://bugreports.qt.io/browse/QTBUG-25365 - if key in {Qt.Key_Up, Qt.Key_Down}: - self.update_decorations_timer.start() - - # This necessary to run our Pygments highlighter again after the - # user generated text changes - if event.text(): - # Stop the active timer and start it again to not run it on - # every event - if self.timer_syntax_highlight.isActive(): - self.timer_syntax_highlight.stop() - - # Adjust interval to rehighlight according to the lines - # present in the file - total_lines = self.get_line_count() - if total_lines < 1000: - self.timer_syntax_highlight.setInterval(600) - elif total_lines < 2000: - self.timer_syntax_highlight.setInterval(800) - else: - self.timer_syntax_highlight.setInterval(1000) - self.timer_syntax_highlight.start() - - self._restore_editor_cursor_and_selections() - super(CodeEditor, self).keyReleaseEvent(event) - event.ignore() - - def event(self, event): - """Qt method override.""" - if event.type() == QEvent.ShortcutOverride: - event.ignore() - return False - else: - return super(CodeEditor, self).event(event) - - def _start_completion_timer(self): - """Helper to start timer for automatic completions or handle them.""" - if not self.automatic_completions: - return - - if self.automatic_completions_after_ms > 0: - self._timer_autocomplete.start( - self.automatic_completions_after_ms) - else: - self._handle_completions() - - def _handle_keypress_event(self, event): - """Handle keypress events.""" - TextEditBaseWidget.keyPressEvent(self, event) - - # Trigger the following actions only if the event generates - # a text change. - text = to_text_string(event.text()) - if text: - # The next three lines are a workaround for a quirk of - # QTextEdit on Linux with Qt < 5.15, MacOs and Windows. - # See spyder-ide/spyder#12663 and - # https://bugreports.qt.io/browse/QTBUG-35861 - if (parse_version(QT_VERSION) < parse_version('5.15') - or os.name == 'nt' or sys.platform == 'darwin'): - cursor = self.textCursor() - cursor.setPosition(cursor.position()) - self.setTextCursor(cursor) - self.sig_text_was_inserted.emit() - - def keyPressEvent(self, event): - """Reimplement Qt method.""" - tab_pressed = False - if self.completions_hint_after_ms > 0: - self._completions_hint_idle = False - self._timer_completions_hint.start(self.completions_hint_after_ms) - else: - self._set_completions_hint_idle() - - # Send the signal to the editor's extension. - event.ignore() - self.sig_key_pressed.emit(event) - - self.kite_call_to_action.handle_key_press(event) - - key = event.key() - text = to_text_string(event.text()) - has_selection = self.has_selected_text() - ctrl = event.modifiers() & Qt.ControlModifier - shift = event.modifiers() & Qt.ShiftModifier - - if text: - self.__clear_occurrences() - - # Only ask for completions if there's some text generated - # as part of the event. Events such as pressing Crtl, - # Shift or Alt don't generate any text. - # Fixes spyder-ide/spyder#11021 - self._start_completion_timer() - - if event.modifiers() and self.is_completion_widget_visible(): - # Hide completion widget before passing event modifiers - # since the keypress could be then a shortcut - # See spyder-ide/spyder#14806 - self.completion_widget.hide() - - if key in {Qt.Key_Up, Qt.Key_Left, Qt.Key_Right, Qt.Key_Down}: - self.hide_tooltip() - - if event.isAccepted(): - # The event was handled by one of the editor extension. - return - - if key in [Qt.Key_Control, Qt.Key_Shift, Qt.Key_Alt, - Qt.Key_Meta, Qt.KeypadModifier]: - # The user pressed only a modifier key. - if ctrl: - pos = self.mapFromGlobal(QCursor.pos()) - pos = self.calculate_real_position_from_global(pos) - if self._handle_goto_uri_event(pos): - event.accept() - return - - if self._handle_goto_definition_event(pos): - event.accept() - return - return - - # ---- Handle hard coded and builtin actions - operators = {'+', '-', '*', '**', '/', '//', '%', '@', '<<', '>>', - '&', '|', '^', '~', '<', '>', '<=', '>=', '==', '!='} - delimiters = {',', ':', ';', '@', '=', '->', '+=', '-=', '*=', '/=', - '//=', '%=', '@=', '&=', '|=', '^=', '>>=', '<<=', '**='} - - if text not in self.auto_completion_characters: - if text in operators or text in delimiters: - self.completion_widget.hide() - if key in (Qt.Key_Enter, Qt.Key_Return): - if not shift and not ctrl: - if (self.add_colons_enabled and self.is_python_like() and - self.autoinsert_colons()): - self.textCursor().beginEditBlock() - self.insert_text(':' + self.get_line_separator()) - if self.strip_trailing_spaces_on_modify: - self.fix_and_strip_indent() - else: - self.fix_indent() - self.textCursor().endEditBlock() - elif self.is_completion_widget_visible(): - self.select_completion_list() - else: - self.textCursor().beginEditBlock() - cur_indent = self.get_block_indentation( - self.textCursor().blockNumber()) - self._handle_keypress_event(event) - # Check if we're in a comment or a string at the - # current position - cmt_or_str_cursor = self.in_comment_or_string() - - # Check if the line start with a comment or string - cursor = self.textCursor() - cursor.setPosition(cursor.block().position(), - QTextCursor.KeepAnchor) - cmt_or_str_line_begin = self.in_comment_or_string( - cursor=cursor) - - # Check if we are in a comment or a string - cmt_or_str = cmt_or_str_cursor and cmt_or_str_line_begin - - if self.strip_trailing_spaces_on_modify: - self.fix_and_strip_indent( - comment_or_string=cmt_or_str, - cur_indent=cur_indent) - else: - self.fix_indent(comment_or_string=cmt_or_str, - cur_indent=cur_indent) - self.textCursor().endEditBlock() - elif key == Qt.Key_Insert and not shift and not ctrl: - self.setOverwriteMode(not self.overwriteMode()) - elif key == Qt.Key_Backspace and not shift and not ctrl: - if has_selection or not self.intelligent_backspace: - self._handle_keypress_event(event) - else: - leading_text = self.get_text('sol', 'cursor') - leading_length = len(leading_text) - trailing_spaces = leading_length - len(leading_text.rstrip()) - trailing_text = self.get_text('cursor', 'eol') - matches = ('()', '[]', '{}', '\'\'', '""') - if (not leading_text.strip() and - (leading_length > len(self.indent_chars))): - if leading_length % len(self.indent_chars) == 0: - self.unindent() - else: - self._handle_keypress_event(event) - elif trailing_spaces and not trailing_text.strip(): - self.remove_suffix(leading_text[-trailing_spaces:]) - elif (leading_text and trailing_text and - (leading_text[-1] + trailing_text[0] in matches)): - cursor = self.textCursor() - cursor.movePosition(QTextCursor.PreviousCharacter) - cursor.movePosition(QTextCursor.NextCharacter, - QTextCursor.KeepAnchor, 2) - cursor.removeSelectedText() - else: - self._handle_keypress_event(event) - elif key == Qt.Key_Home: - self.stdkey_home(shift, ctrl) - elif key == Qt.Key_End: - # See spyder-ide/spyder#495: on MacOS X, it is necessary to - # redefine this basic action which should have been implemented - # natively - self.stdkey_end(shift, ctrl) - elif (text in self.auto_completion_characters and - self.automatic_completions): - self.insert_text(text) - if text == ".": - if not self.in_comment_or_string(): - text = self.get_text('sol', 'cursor') - last_obj = getobj(text) - prev_char = text[-2] if len(text) > 1 else '' - if (prev_char in {')', ']', '}'} or - (last_obj and not last_obj.isdigit())): - # Completions should be triggered immediately when - # an autocompletion character is introduced. - self.do_completion(automatic=True) - else: - self.do_completion(automatic=True) - elif (text in self.signature_completion_characters and - not self.has_selected_text()): - self.insert_text(text) - self.request_signature() - elif (key == Qt.Key_Colon and not has_selection and - self.auto_unindent_enabled): - leading_text = self.get_text('sol', 'cursor') - if leading_text.lstrip() in ('else', 'finally'): - ind = lambda txt: len(txt) - len(txt.lstrip()) - prevtxt = (to_text_string(self.textCursor().block(). - previous().text())) - if self.language == 'Python': - prevtxt = prevtxt.rstrip() - if ind(leading_text) == ind(prevtxt): - self.unindent(force=True) - self._handle_keypress_event(event) - elif (key == Qt.Key_Space and not shift and not ctrl and not - has_selection and self.auto_unindent_enabled): - self.completion_widget.hide() - leading_text = self.get_text('sol', 'cursor') - if leading_text.lstrip() in ('elif', 'except'): - ind = lambda txt: len(txt)-len(txt.lstrip()) - prevtxt = (to_text_string(self.textCursor().block(). - previous().text())) - if self.language == 'Python': - prevtxt = prevtxt.rstrip() - if ind(leading_text) == ind(prevtxt): - self.unindent(force=True) - self._handle_keypress_event(event) - elif key == Qt.Key_Tab and not ctrl: - # Important note: can't be called with a QShortcut because - # of its singular role with respect to widget focus management - tab_pressed = True - if not has_selection and not self.tab_mode: - self.intelligent_tab() - else: - # indent the selected text - self.indent_or_replace() - elif key == Qt.Key_Backtab and not ctrl: - # Backtab, i.e. Shift+, could be treated as a QShortcut but - # there is no point since can't (see above) - tab_pressed = True - if not has_selection and not self.tab_mode: - self.intelligent_backtab() - else: - # indent the selected text - self.unindent() - event.accept() - elif not event.isAccepted(): - self._handle_keypress_event(event) - - self._last_key_pressed_text = text - self._last_pressed_key = key - if self.automatic_completions_after_ms == 0 and not tab_pressed: - self._handle_completions() - - if not event.modifiers(): - # Accept event to avoid it being handled by the parent. - # Modifiers should be passed to the parent because they - # could be shortcuts - event.accept() - - def _handle_completions(self): - """Handle on the fly completions after delay.""" - if not self.automatic_completions: - return - - cursor = self.textCursor() - pos = cursor.position() - cursor.select(QTextCursor.WordUnderCursor) - text = to_text_string(cursor.selectedText()) - - key = self._last_pressed_key - if key is not None: - if key in [Qt.Key_Return, Qt.Key_Escape, - Qt.Key_Tab, Qt.Key_Backtab, Qt.Key_Space]: - self._last_pressed_key = None - return - - # Correctly handle completions when Backspace key is pressed. - # We should not show the widget if deleting a space before a word. - if key == Qt.Key_Backspace: - cursor.setPosition(pos - 1, QTextCursor.MoveAnchor) - cursor.select(QTextCursor.WordUnderCursor) - prev_text = to_text_string(cursor.selectedText()) - cursor.setPosition(pos - 1, QTextCursor.MoveAnchor) - cursor.setPosition(pos, QTextCursor.KeepAnchor) - prev_char = cursor.selectedText() - if prev_text == '' or prev_char in (u'\u2029', ' ', '\t'): - return - - # Text might be after a dot '.' - if text == '': - cursor.setPosition(pos - 1, QTextCursor.MoveAnchor) - cursor.select(QTextCursor.WordUnderCursor) - text = to_text_string(cursor.selectedText()) - if text != '.': - text = '' - - # WordUnderCursor fails if the cursor is next to a right brace. - # If the returned text starts with it, we move to the left. - if text.startswith((')', ']', '}')): - cursor.setPosition(pos - 1, QTextCursor.MoveAnchor) - cursor.select(QTextCursor.WordUnderCursor) - text = to_text_string(cursor.selectedText()) - - is_backspace = ( - self.is_completion_widget_visible() and key == Qt.Key_Backspace) - - if (len(text) >= self.automatic_completions_after_chars - and self._last_key_pressed_text or is_backspace): - # Perform completion on the fly - if not self.in_comment_or_string(): - # Variables can include numbers and underscores - if (text.isalpha() or text.isalnum() or '_' in text - or '.' in text): - self.do_completion(automatic=True) - self._last_key_pressed_text = '' - self._last_pressed_key = None - - def fix_and_strip_indent(self, *args, **kwargs): - """ - Automatically fix indent and strip previous automatic indent. - - args and kwargs are forwarded to self.fix_indent - """ - # Fix indent - cursor_before = self.textCursor().position() - # A change just occurred on the last line (return was pressed) - if cursor_before > 0: - self.last_change_position = cursor_before - 1 - self.fix_indent(*args, **kwargs) - cursor_after = self.textCursor().position() - # Remove previous spaces and update last_auto_indent - nspaces_removed = self.strip_trailing_spaces() - self.last_auto_indent = (cursor_before - nspaces_removed, - cursor_after - nspaces_removed) - - def run_pygments_highlighter(self): - """Run pygments highlighter.""" - if isinstance(self.highlighter, sh.PygmentsSH): - self.highlighter.make_charlist() - - def get_pattern_at(self, coordinates): - """ - Return key, text and cursor for pattern (if found at coordinates). - """ - return self.get_pattern_cursor_at(self.highlighter.patterns, - coordinates) - - def get_pattern_cursor_at(self, pattern, coordinates): - """ - Find pattern located at the line where the coordinate is located. - - This returns the actual match and the cursor that selects the text. - """ - cursor, key, text = None, None, None - break_loop = False - - # Check if the pattern is in line - line = self.get_line_at(coordinates) - - for match in pattern.finditer(line): - for key, value in list(match.groupdict().items()): - if value: - start, end = sh.get_span(match) - - # Get cursor selection if pattern found - cursor = self.cursorForPosition(coordinates) - cursor.movePosition(QTextCursor.StartOfBlock) - line_start_position = cursor.position() - - cursor.setPosition(line_start_position + start, - cursor.MoveAnchor) - start_rect = self.cursorRect(cursor) - cursor.setPosition(line_start_position + end, - cursor.MoveAnchor) - end_rect = self.cursorRect(cursor) - bounding_rect = start_rect.united(end_rect) - - # Check coordinates are located within the selection rect - if bounding_rect.contains(coordinates): - text = line[start:end] - cursor.setPosition(line_start_position + start, - cursor.KeepAnchor) - break_loop = True - break - - if break_loop: - break - - return key, text, cursor - - def _preprocess_file_uri(self, uri): - """Format uri to conform to absolute or relative file paths.""" - fname = uri.replace('file://', '') - if fname[-1] == '/': - fname = fname[:-1] - - # ^/ is used to denote the current project root - if fname.startswith("^/"): - if self.current_project_path is not None: - fname = osp.join(self.current_project_path, fname[2:]) - else: - fname = fname.replace("^/", "~/") - - if fname.startswith("~/"): - fname = osp.expanduser(fname) - - dirname = osp.dirname(osp.abspath(self.filename)) - if osp.isdir(dirname): - if not osp.isfile(fname): - # Maybe relative - fname = osp.join(dirname, fname) - - self.sig_file_uri_preprocessed.emit(fname) - - return fname - - def _handle_goto_definition_event(self, pos): - """Check if goto definition can be applied and apply highlight.""" - text = self.get_word_at(pos) - if text and not sourcecode.is_keyword(to_text_string(text)): - if not self.__cursor_changed: - QApplication.setOverrideCursor(QCursor(Qt.PointingHandCursor)) - self.__cursor_changed = True - cursor = self.cursorForPosition(pos) - cursor.select(QTextCursor.WordUnderCursor) - self.clear_extra_selections('ctrl_click') - self.highlight_selection( - 'ctrl_click', cursor, - foreground_color=self.ctrl_click_color, - underline_color=self.ctrl_click_color, - underline_style=QTextCharFormat.SingleUnderline) - return True - else: - return False - - def _handle_goto_uri_event(self, pos): - """Check if go to uri can be applied and apply highlight.""" - key, pattern_text, cursor = self.get_pattern_at(pos) - if key and pattern_text and cursor: - self._last_hover_pattern_key = key - self._last_hover_pattern_text = pattern_text - - color = self.ctrl_click_color - - if key in ['file']: - fname = self._preprocess_file_uri(pattern_text) - if not osp.isfile(fname): - color = QColor(SpyderPalette.COLOR_ERROR_2) - - self.clear_extra_selections('ctrl_click') - self.highlight_selection( - 'ctrl_click', cursor, - foreground_color=color, - underline_color=color, - underline_style=QTextCharFormat.SingleUnderline) - - if not self.__cursor_changed: - QApplication.setOverrideCursor( - QCursor(Qt.PointingHandCursor)) - self.__cursor_changed = True - - self.sig_uri_found.emit(pattern_text) - return True - else: - self._last_hover_pattern_key = key - self._last_hover_pattern_text = pattern_text - return False - - def go_to_uri_from_cursor(self, uri): - """Go to url from cursor and defined hover patterns.""" - key = self._last_hover_pattern_key - full_uri = uri - - if key in ['file']: - fname = self._preprocess_file_uri(uri) - - if osp.isfile(fname) and encoding.is_text_file(fname): - # Open in editor - self.go_to_definition.emit(fname, 0, 0) - else: - # Use external program - fname = file_uri(fname) - start_file(fname) - elif key in ['mail', 'url']: - if '@' in uri and not uri.startswith('mailto:'): - full_uri = 'mailto:' + uri - quri = QUrl(full_uri) - QDesktopServices.openUrl(quri) - elif key in ['issue']: - # Issue URI - repo_url = uri.replace('#', '/issues/') - if uri.startswith(('gh-', 'bb-', 'gl-')): - number = uri[3:] - remotes = get_git_remotes(self.filename) - remote = remotes.get('upstream', remotes.get('origin')) - if remote: - full_uri = remote_to_url(remote) + '/issues/' + number - else: - full_uri = None - elif uri.startswith('gh:') or ':' not in uri: - # Github - repo_and_issue = repo_url - if uri.startswith('gh:'): - repo_and_issue = repo_url[3:] - full_uri = 'https://github.com/' + repo_and_issue - elif uri.startswith('gl:'): - # Gitlab - full_uri = 'https://gitlab.com/' + repo_url[3:] - elif uri.startswith('bb:'): - # Bitbucket - full_uri = 'https://bitbucket.org/' + repo_url[3:] - - if full_uri: - quri = QUrl(full_uri) - QDesktopServices.openUrl(quri) - else: - QMessageBox.information( - self, - _('Information'), - _('This file is not part of a local repository or ' - 'upstream/origin remotes are not defined!'), - QMessageBox.Ok, - ) - self.hide_tooltip() - return full_uri - - def line_range(self, position): - """ - Get line range from position. - """ - if position is None: - return None - if position >= self.document().characterCount(): - return None - # Check if still on the line - cursor = self.textCursor() - cursor.setPosition(position) - line_range = (cursor.block().position(), - cursor.block().position() - + cursor.block().length() - 1) - return line_range - - def strip_trailing_spaces(self): - """ - Strip trailing spaces if needed. - - Remove trailing whitespace on leaving a non-string line containing it. - Return the number of removed spaces. - """ - if not running_under_pytest(): - if not self.hasFocus(): - # Avoid problem when using split editor - return 0 - # Update current position - current_position = self.textCursor().position() - last_position = self.last_position - self.last_position = current_position - - if self.skip_rstrip: - return 0 - - line_range = self.line_range(last_position) - if line_range is None: - # Doesn't apply - return 0 - - def pos_in_line(pos): - """Check if pos is in last line.""" - if pos is None: - return False - return line_range[0] <= pos <= line_range[1] - - if pos_in_line(current_position): - # Check if still on the line - return 0 - - # Check if end of line in string - cursor = self.textCursor() - cursor.setPosition(line_range[1]) - - if (not self.strip_trailing_spaces_on_modify - or self.in_string(cursor=cursor)): - if self.last_auto_indent is None: - return 0 - elif (self.last_auto_indent != - self.line_range(self.last_auto_indent[0])): - # line not empty - self.last_auto_indent = None - return 0 - line_range = self.last_auto_indent - self.last_auto_indent = None - elif not pos_in_line(self.last_change_position): - # Should process if pressed return or made a change on the line: - return 0 - - cursor.setPosition(line_range[0]) - cursor.setPosition(line_range[1], - QTextCursor.KeepAnchor) - # remove spaces on the right - text = cursor.selectedText() - strip = text.rstrip() - # I think all the characters we can strip are in a single QChar. - # Therefore there shouldn't be any length problems. - N_strip = qstring_length(text[len(strip):]) - - if N_strip > 0: - # Select text to remove - cursor.setPosition(line_range[1] - N_strip) - cursor.setPosition(line_range[1], - QTextCursor.KeepAnchor) - cursor.removeSelectedText() - # Correct last change position - self.last_change_position = line_range[1] - self.last_position = self.textCursor().position() - return N_strip - return 0 - - def move_line_up(self): - """Move up current line or selected text""" - self.__move_line_or_selection(after_current_line=False) - - def move_line_down(self): - """Move down current line or selected text""" - self.__move_line_or_selection(after_current_line=True) - - def __move_line_or_selection(self, after_current_line=True): - cursor = self.textCursor() - # Unfold any folded code block before moving lines up/down - folding_panel = self.panels.get('FoldingPanel') - fold_start_line = cursor.blockNumber() + 1 - block = cursor.block().next() - - if fold_start_line in folding_panel.folding_status: - fold_status = folding_panel.folding_status[fold_start_line] - if fold_status: - folding_panel.toggle_fold_trigger(block) - - if after_current_line: - # Unfold any folded region when moving lines down - fold_start_line = cursor.blockNumber() + 2 - block = cursor.block().next().next() - - if fold_start_line in folding_panel.folding_status: - fold_status = folding_panel.folding_status[fold_start_line] - if fold_status: - folding_panel.toggle_fold_trigger(block) - else: - # Unfold any folded region when moving lines up - block = cursor.block() - offset = 0 - if self.has_selected_text(): - ((selection_start, _), - (selection_end)) = self.get_selection_start_end() - if selection_end != selection_start: - offset = 1 - fold_start_line = block.blockNumber() - 1 - offset - - # Find the innermost code folding region for the current position - enclosing_regions = sorted(list( - folding_panel.current_tree[fold_start_line])) - - folding_status = folding_panel.folding_status - if len(enclosing_regions) > 0: - for region in enclosing_regions: - fold_start_line = region.begin - block = self.document().findBlockByNumber(fold_start_line) - if fold_start_line in folding_status: - fold_status = folding_status[fold_start_line] - if fold_status: - folding_panel.toggle_fold_trigger(block) - - self._TextEditBaseWidget__move_line_or_selection( - after_current_line=after_current_line) - - def mouseMoveEvent(self, event): - """Underline words when pressing """ - # Restart timer every time the mouse is moved - # This is needed to correctly handle hover hints with a delay - self._timer_mouse_moving.start() - - pos = event.pos() - self._last_point = pos - alt = event.modifiers() & Qt.AltModifier - ctrl = event.modifiers() & Qt.ControlModifier - - if alt: - self.sig_alt_mouse_moved.emit(event) - event.accept() - return - - if ctrl: - if self._handle_goto_uri_event(pos): - event.accept() - return - - if self.has_selected_text(): - TextEditBaseWidget.mouseMoveEvent(self, event) - return - - if self.go_to_definition_enabled and ctrl: - if self._handle_goto_definition_event(pos): - event.accept() - return - - if self.__cursor_changed: - self._restore_editor_cursor_and_selections() - else: - if (not self._should_display_hover(pos) - and not self.is_completion_widget_visible()): - self.hide_tooltip() - - TextEditBaseWidget.mouseMoveEvent(self, event) - - def setPlainText(self, txt): - """ - Extends setPlainText to emit the new_text_set signal. - - :param txt: The new text to set. - :param mime_type: Associated mimetype. Setting the mime will update the - pygments lexer. - :param encoding: text encoding - """ - super(CodeEditor, self).setPlainText(txt) - self.new_text_set.emit() - - def focusOutEvent(self, event): - """Extend Qt method""" - self.sig_focus_changed.emit() - self._restore_editor_cursor_and_selections() - super(CodeEditor, self).focusOutEvent(event) - - def focusInEvent(self, event): - formatting_enabled = getattr(self, 'formatting_enabled', False) - self.sig_refresh_formatting.emit(formatting_enabled) - super(CodeEditor, self).focusInEvent(event) - - def leaveEvent(self, event): - """Extend Qt method""" - self.sig_leave_out.emit() - self._restore_editor_cursor_and_selections() - TextEditBaseWidget.leaveEvent(self, event) - - def mousePressEvent(self, event): - """Override Qt method.""" - self.hide_tooltip() - self.kite_call_to_action.handle_mouse_press(event) - - ctrl = event.modifiers() & Qt.ControlModifier - alt = event.modifiers() & Qt.AltModifier - pos = event.pos() - self._mouse_left_button_pressed = event.button() == Qt.LeftButton - - if event.button() == Qt.LeftButton and ctrl: - TextEditBaseWidget.mousePressEvent(self, event) - cursor = self.cursorForPosition(pos) - uri = self._last_hover_pattern_text - if uri: - self.go_to_uri_from_cursor(uri) - else: - self.go_to_definition_from_cursor(cursor) - elif event.button() == Qt.LeftButton and alt: - self.sig_alt_left_mouse_pressed.emit(event) - else: - TextEditBaseWidget.mousePressEvent(self, event) - - def mouseReleaseEvent(self, event): - """Override Qt method.""" - if event.button() == Qt.LeftButton: - self._mouse_left_button_pressed = False - - self.request_cursor_event() - TextEditBaseWidget.mouseReleaseEvent(self, event) - - def contextMenuEvent(self, event): - """Reimplement Qt method""" - nonempty_selection = self.has_selected_text() - self.copy_action.setEnabled(nonempty_selection) - self.cut_action.setEnabled(nonempty_selection) - self.clear_all_output_action.setVisible(self.is_json() and - nbformat is not None) - self.ipynb_convert_action.setVisible(self.is_json() and - nbformat is not None) - self.run_cell_action.setVisible(self.is_python_or_ipython()) - self.run_cell_and_advance_action.setVisible(self.is_python_or_ipython()) - self.run_selection_action.setVisible(self.is_python_or_ipython()) - self.run_to_line_action.setVisible(self.is_python_or_ipython()) - self.run_from_line_action.setVisible(self.is_python_or_ipython()) - self.re_run_last_cell_action.setVisible(self.is_python_or_ipython()) - self.gotodef_action.setVisible(self.go_to_definition_enabled) - - formatter = CONF.get( - 'completions', - ('provider_configuration', 'lsp', 'values', 'formatting'), - '' - ) - self.format_action.setText(_( - 'Format file or selection with {0}').format( - formatter.capitalize())) - - # Check if a docstring is writable - writer = self.writer_docstring - writer.line_number_cursor = self.get_line_number_at(event.pos()) - result = writer.get_function_definition_from_first_line() - - if result: - self.docstring_action.setEnabled(True) - else: - self.docstring_action.setEnabled(False) - - # Code duplication go_to_definition_from_cursor and mouse_move_event - cursor = self.textCursor() - text = to_text_string(cursor.selectedText()) - if len(text) == 0: - cursor.select(QTextCursor.WordUnderCursor) - text = to_text_string(cursor.selectedText()) - - self.undo_action.setEnabled(self.document().isUndoAvailable()) - self.redo_action.setEnabled(self.document().isRedoAvailable()) - menu = self.menu - if self.isReadOnly(): - menu = self.readonly_menu - menu.popup(event.globalPos()) - event.accept() - - def _restore_editor_cursor_and_selections(self): - """Restore the cursor and extra selections of this code editor.""" - if self.__cursor_changed: - self.__cursor_changed = False - QApplication.restoreOverrideCursor() - self.clear_extra_selections('ctrl_click') - self._last_hover_pattern_key = None - self._last_hover_pattern_text = None - - #------ Drag and drop - def dragEnterEvent(self, event): - """ - Reimplemented Qt method. - - Inform Qt about the types of data that the widget accepts. - """ - logger.debug("dragEnterEvent was received") - all_urls = mimedata2url(event.mimeData()) - if all_urls: - # Let the parent widget handle this - logger.debug("Let the parent widget handle this dragEnterEvent") - event.ignore() - else: - logger.debug("Call TextEditBaseWidget dragEnterEvent method") - TextEditBaseWidget.dragEnterEvent(self, event) - - def dropEvent(self, event): - """ - Reimplemented Qt method. - - Unpack dropped data and handle it. - """ - logger.debug("dropEvent was received") - if mimedata2url(event.mimeData()): - logger.debug("Let the parent widget handle this") - event.ignore() - else: - logger.debug("Call TextEditBaseWidget dropEvent method") - TextEditBaseWidget.dropEvent(self, event) - - #------ Paint event - def paintEvent(self, event): - """Overrides paint event to update the list of visible blocks""" - self.update_visible_blocks(event) - TextEditBaseWidget.paintEvent(self, event) - self.painted.emit(event) - - def update_visible_blocks(self, event): - """Update the list of visible blocks/lines position""" - self.__visible_blocks[:] = [] - block = self.firstVisibleBlock() - blockNumber = block.blockNumber() - top = int(self.blockBoundingGeometry(block).translated( - self.contentOffset()).top()) - bottom = top + int(self.blockBoundingRect(block).height()) - ebottom_bottom = self.height() - - while block.isValid(): - visible = bottom <= ebottom_bottom - if not visible: - break - if block.isVisible(): - self.__visible_blocks.append((top, blockNumber+1, block)) - block = block.next() - top = bottom - bottom = top + int(self.blockBoundingRect(block).height()) - blockNumber = block.blockNumber() - - def _draw_editor_cell_divider(self): - """Draw a line on top of a define cell""" - if self.supported_cell_language: - cell_line_color = self.comment_color - painter = QPainter(self.viewport()) - pen = painter.pen() - pen.setStyle(Qt.SolidLine) - pen.setBrush(cell_line_color) - painter.setPen(pen) - - for top, line_number, block in self.visible_blocks: - if is_cell_header(block): - painter.drawLine(4, top, self.width(), top) - - @property - def visible_blocks(self): - """ - Returns the list of visible blocks. - - Each element in the list is a tuple made up of the line top position, - the line number (already 1 based), and the QTextBlock itself. - - :return: A list of tuple(top position, line number, block) - :rtype: List of tuple(int, int, QtGui.QTextBlock) - """ - return self.__visible_blocks - - def is_editor(self): - return True - - def popup_docstring(self, prev_text, prev_pos): - """Show the menu for generating docstring.""" - line_text = self.textCursor().block().text() - if line_text != prev_text: - return - - if prev_pos != self.textCursor().position(): - return - - writer = self.writer_docstring - if writer.get_function_definition_from_below_last_line(): - point = self.cursorRect().bottomRight() - point = self.calculate_real_position(point) - point = self.mapToGlobal(point) - - self.menu_docstring = QMenuOnlyForEnter(self) - self.docstring_action = create_action( - self, _("Generate docstring"), icon=ima.icon('TextFileIcon'), - triggered=writer.write_docstring) - self.menu_docstring.addAction(self.docstring_action) - self.menu_docstring.setActiveAction(self.docstring_action) - self.menu_docstring.popup(point) - - def delayed_popup_docstring(self): - """Show context menu for docstring. - - This method is called after typing '''. After typing ''', this function - waits 300ms. If there was no input for 300ms, show the context menu. - """ - line_text = self.textCursor().block().text() - pos = self.textCursor().position() - - timer = QTimer() - timer.singleShot(300, lambda: self.popup_docstring(line_text, pos)) - - def set_current_project_path(self, root_path=None): - """ - Set the current active project root path. - - Parameters - ---------- - root_path: str or None, optional - Path to current project root path. Default is None. - """ - self.current_project_path = root_path - - def count_leading_empty_lines(self, cell): - """Count the number of leading empty cells.""" - lines = cell.splitlines(keepends=True) - if not lines: - return 0 - for i, line in enumerate(lines): - if line and not line.isspace(): - return i - return len(lines) - - def ipython_to_python(self, code): - """Transform IPython code to python code.""" - tm = TransformerManager() - number_empty_lines = self.count_leading_empty_lines(code) - try: - code = tm.transform_cell(code) - except SyntaxError: - return code - return '\n' * number_empty_lines + code - - def is_letter_or_number(self, char): - """ - Returns whether the specified unicode character is a letter or a - number. - """ - cat = category(char) - return cat.startswith('L') or cat.startswith('N') - - -# ============================================================================= -# Editor + Class browser test -# ============================================================================= -class TestWidget(QSplitter): - def __init__(self, parent): - QSplitter.__init__(self, parent) - self.editor = CodeEditor(self) - self.editor.setup_editor(linenumbers=True, markers=True, tab_mode=False, - font=QFont("Courier New", 10), - show_blanks=True, color_scheme='Zenburn') - self.addWidget(self.editor) - self.setWindowIcon(ima.icon('spyder')) - - def load(self, filename): - self.editor.set_text_from_file(filename) - self.setWindowTitle("%s - %s (%s)" % (_("Editor"), - osp.basename(filename), - osp.dirname(filename))) - self.editor.hide_tooltip() - - -def test(fname): - from spyder.utils.qthelpers import qapplication - app = qapplication(test_time=5) - win = TestWidget(None) - win.show() - win.load(fname) - win.resize(900, 700) - sys.exit(app.exec_()) - - -if __name__ == '__main__': - if len(sys.argv) > 1: - fname = sys.argv[1] - else: - fname = __file__ - test(fname) +# -*- coding: utf-8 -*- +# +# Copyright © Spyder Project Contributors +# Licensed under the terms of the MIT License +# (see spyder/__init__.py for details) + +""" +Editor widget based on QtGui.QPlainTextEdit +""" + +# TODO: Try to separate this module from spyder to create a self +# consistent editor module (Qt source code and shell widgets library) + +# pylint: disable=C0103 +# pylint: disable=R0903 +# pylint: disable=R0911 +# pylint: disable=R0201 + +# Standard library imports +from unicodedata import category +import logging +import functools +import os +import os.path as osp +import re +import sre_constants +import sys +import textwrap +from pkg_resources import parse_version + +# Third party imports +from diff_match_patch import diff_match_patch +from IPython.core.inputtransformer2 import TransformerManager +from qtpy import QT_VERSION +from qtpy.compat import to_qvariant +from qtpy.QtCore import (QEvent, QEventLoop, QRegExp, Qt, QTimer, QThread, + QUrl, Signal, Slot) +from qtpy.QtGui import (QColor, QCursor, QFont, QKeySequence, QPaintEvent, + QPainter, QMouseEvent, QTextCursor, QDesktopServices, + QKeyEvent, QTextDocument, QTextFormat, QTextOption, + QTextCharFormat, QTextLayout) +from qtpy.QtWidgets import (QApplication, QMenu, QMessageBox, QSplitter, + QScrollBar) +from spyder_kernels.utils.dochelpers import getobj +from three_merge import merge + + +# Local imports +from spyder.api.panel import Panel +from spyder.config.base import _, get_debug_level, running_under_pytest +from spyder.config.manager import CONF +from spyder.plugins.editor.api.decoration import TextDecoration +from spyder.plugins.editor.extensions import (CloseBracketsExtension, + CloseQuotesExtension, + DocstringWriterExtension, + QMenuOnlyForEnter, + EditorExtensionsManager, + SnippetsExtension) +from spyder.plugins.completion.providers.kite.widgets import KiteCallToAction +from spyder.plugins.completion.api import (CompletionRequestTypes, + TextDocumentSyncKind, + DiagnosticSeverity) +from spyder.plugins.editor.panels import (ClassFunctionDropdown, + DebuggerPanel, EdgeLine, + FoldingPanel, IndentationGuide, + LineNumberArea, PanelsManager, + ScrollFlagArea) +from spyder.plugins.editor.utils.editor import (TextHelper, BlockUserData, + get_file_language) +from spyder.plugins.editor.utils.debugger import DebuggerManager +from spyder.plugins.editor.utils.kill_ring import QtKillRing +from spyder.plugins.editor.utils.languages import ALL_LANGUAGES, CELL_LANGUAGES +from spyder.plugins.editor.panels.utils import ( + merge_folding, collect_folding_regions) +from spyder.plugins.completion.decorators import ( + request, handles, class_register) +from spyder.plugins.editor.widgets.codeeditor_widgets import GoToLineDialog +from spyder.plugins.editor.widgets.base import TextEditBaseWidget +from spyder.plugins.outlineexplorer.api import (OutlineExplorerData as OED, + is_cell_header) +from spyder.py3compat import PY2, to_text_string, is_string, is_text_string +from spyder.utils import encoding, sourcecode +from spyder.utils.clipboard_helper import CLIPBOARD_HELPER +from spyder.utils.icon_manager import ima +from spyder.utils import syntaxhighlighters as sh +from spyder.utils.palette import SpyderPalette, QStylePalette +from spyder.utils.qthelpers import (add_actions, create_action, file_uri, + mimedata2url, start_file) +from spyder.utils.vcs import get_git_remotes, remote_to_url +from spyder.utils.qstringhelpers import qstring_length + + +try: + import nbformat as nbformat + from nbconvert import PythonExporter as nbexporter +except Exception: + nbformat = None # analysis:ignore + +logger = logging.getLogger(__name__) + + +# Regexp to detect noqa inline comments. +NOQA_INLINE_REGEXP = re.compile(r"#?noqa", re.IGNORECASE) + + +def schedule_request(req=None, method=None, requires_response=True): + """Call function req and then emit its results to the completion server.""" + if req is None: + return functools.partial(schedule_request, method=method, + requires_response=requires_response) + + @functools.wraps(req) + def wrapper(self, *args, **kwargs): + params = req(self, *args, **kwargs) + if params is not None and self.completions_available: + self._pending_server_requests.append( + (method, params, requires_response)) + self._server_requests_timer.start() + return wrapper + + +@class_register +class CodeEditor(TextEditBaseWidget): + """Source Code Editor Widget based exclusively on Qt""" + + LANGUAGES = { + 'Python': (sh.PythonSH, '#'), + 'IPython': (sh.IPythonSH, '#'), + 'Cython': (sh.CythonSH, '#'), + 'Fortran77': (sh.Fortran77SH, 'c'), + 'Fortran': (sh.FortranSH, '!'), + 'Idl': (sh.IdlSH, ';'), + 'Diff': (sh.DiffSH, ''), + 'GetText': (sh.GetTextSH, '#'), + 'Nsis': (sh.NsisSH, '#'), + 'Html': (sh.HtmlSH, ''), + 'Yaml': (sh.YamlSH, '#'), + 'Cpp': (sh.CppSH, '//'), + 'OpenCL': (sh.OpenCLSH, '//'), + 'Enaml': (sh.EnamlSH, '#'), + 'Markdown': (sh.MarkdownSH, '#'), + # Every other language + 'None': (sh.TextSH, ''), + } + + TAB_ALWAYS_INDENTS = ( + 'py', 'pyw', 'python', 'ipy', 'c', 'cpp', 'cl', 'h', 'pyt', 'pyi' + ) + + # Timeout to update decorations (through a QTimer) when a position + # changed is detected in the vertical scrollbar or when releasing + # the up/down arrow keys. + UPDATE_DECORATIONS_TIMEOUT = 500 # milliseconds + + # Timeouts (in milliseconds) to sychronize symbols and folding after + # linting results arrive, according to the number of lines in the file. + SYNC_SYMBOLS_AND_FOLDING_TIMEOUTS = { + # Lines: Timeout + 500: 350, + 1500: 800, + 2500: 1200, + 6500: 1800 + } + + # Custom signal to be emitted upon completion of the editor's paintEvent + painted = Signal(QPaintEvent) + + # To have these attrs when early viewportEvent's are triggered + edge_line = None + indent_guides = None + + sig_breakpoints_changed = Signal() + sig_repaint_breakpoints = Signal() + sig_debug_stop = Signal((int,), ()) + sig_debug_start = Signal() + sig_breakpoints_saved = Signal() + sig_filename_changed = Signal(str) + sig_bookmarks_changed = Signal() + go_to_definition = Signal(str, int, int) + sig_show_object_info = Signal(int) + sig_run_selection = Signal() + sig_run_to_line = Signal() + sig_run_from_line = Signal() + sig_run_cell_and_advance = Signal() + sig_run_cell = Signal() + sig_re_run_last_cell = Signal() + sig_debug_cell = Signal() + sig_cursor_position_changed = Signal(int, int) + sig_new_file = Signal(str) + sig_refresh_formatting = Signal(bool) + + #: Signal emitted when the editor loses focus + sig_focus_changed = Signal() + + #: Signal emitted when a key is pressed + sig_key_pressed = Signal(QKeyEvent) + + #: Signal emitted when a key is released + sig_key_released = Signal(QKeyEvent) + + #: Signal emitted when the alt key is pressed and the left button of the + # mouse is clicked + sig_alt_left_mouse_pressed = Signal(QMouseEvent) + + #: Signal emitted when the alt key is pressed and the cursor moves over + # the editor + sig_alt_mouse_moved = Signal(QMouseEvent) + + #: Signal emitted when the cursor leaves the editor + sig_leave_out = Signal() + + #: Signal emitted when the flags need to be updated in the scrollflagarea + sig_flags_changed = Signal() + + #: Signal emitted when the syntax color theme of the editor. + sig_theme_colors_changed = Signal(dict) + + #: Signal emitted when a new text is set on the widget + new_text_set = Signal() + + # -- LSP signals + #: Signal emitted when an LSP request is sent to the LSP manager + sig_perform_completion_request = Signal(str, str, dict) + + #: Signal emitted when a response is received from the completion plugin + # For now it's only used on tests, but it could be used to track + # and profile completion diagnostics. + completions_response_signal = Signal(str, object) + + #: Signal to display object information on the Help plugin + sig_display_object_info = Signal(str, bool) + + #: Signal only used for tests + # TODO: Remove it! + sig_signature_invoked = Signal(dict) + + #: Signal emitted when processing code analysis warnings is finished + sig_process_code_analysis = Signal() + + # Used for testing. When the mouse moves with Ctrl/Cmd pressed and + # a URI is found, this signal is emitted + sig_uri_found = Signal(str) + + sig_file_uri_preprocessed = Signal(str) + """ + This signal is emitted when the go to uri for a file has been + preprocessed. + + Parameters + ---------- + fpath: str + The preprocessed file path. + """ + + # Signal with the info about the current completion item documentation + # str: object name + # str: object signature/documentation + # bool: force showing the info + sig_show_completion_object_info = Signal(str, str, bool) + + # Used to indicate if text was inserted into the editor + sig_text_was_inserted = Signal() + + # Used to indicate that text will be inserted into the editor + sig_will_insert_text = Signal(str) + + # Used to indicate that a text selection will be removed + sig_will_remove_selection = Signal(tuple, tuple) + + # Used to indicate that text will be pasted + sig_will_paste_text = Signal(str) + + # Used to indicate that an undo operation will take place + sig_undo = Signal() + + # Used to indicate that an undo operation will take place + sig_redo = Signal() + + # Used to start the status spinner in the editor + sig_start_operation_in_progress = Signal() + + # Used to start the status spinner in the editor + sig_stop_operation_in_progress = Signal() + + # Used to signal font change + sig_font_changed = Signal() + + def __init__(self, parent=None): + TextEditBaseWidget.__init__(self, parent) + + self.setFocusPolicy(Qt.StrongFocus) + + # Projects + self.current_project_path = None + + # Caret (text cursor) + self.setCursorWidth(CONF.get('main', 'cursor/width')) + + self.text_helper = TextHelper(self) + + self._panels = PanelsManager(self) + + # Mouse moving timer / Hover hints handling + # See: mouseMoveEvent + self.tooltip_widget.sig_help_requested.connect( + self.show_object_info) + self.tooltip_widget.sig_completion_help_requested.connect( + self.show_completion_object_info) + self._last_point = None + self._last_hover_word = None + self._last_hover_cursor = None + self._timer_mouse_moving = QTimer(self) + self._timer_mouse_moving.setInterval(350) + self._timer_mouse_moving.setSingleShot(True) + self._timer_mouse_moving.timeout.connect(self._handle_hover) + + # Typing keys / handling on the fly completions + # See: keyPressEvent + self._last_key_pressed_text = '' + self._last_pressed_key = None + self._timer_autocomplete = QTimer(self) + self._timer_autocomplete.setSingleShot(True) + self._timer_autocomplete.timeout.connect(self._handle_completions) + + # Handle completions hints + self._completions_hint_idle = False + self._timer_completions_hint = QTimer(self) + self._timer_completions_hint.setSingleShot(True) + self._timer_completions_hint.timeout.connect( + self._set_completions_hint_idle) + self.completion_widget.sig_completion_hint.connect( + self.show_hint_for_completion) + + # Request symbols and folding after a timeout. + # See: process_diagnostics + self._timer_sync_symbols_and_folding = QTimer(self) + self._timer_sync_symbols_and_folding.setSingleShot(True) + self._timer_sync_symbols_and_folding.timeout.connect( + self.sync_symbols_and_folding) + self.blockCountChanged.connect( + self.set_sync_symbols_and_folding_timeout) + + # Goto uri + self._last_hover_pattern_key = None + self._last_hover_pattern_text = None + + # 79-col edge line + self.edge_line = self.panels.register(EdgeLine(), + Panel.Position.FLOATING) + + # indent guides + self.indent_guides = self.panels.register(IndentationGuide(), + Panel.Position.FLOATING) + # Blanks enabled + self.blanks_enabled = False + + # Underline errors and warnings + self.underline_errors_enabled = False + + # Scrolling past the end of the document + self.scrollpastend_enabled = False + + self.background = QColor('white') + + # Folding + self.panels.register(FoldingPanel()) + + # Debugger panel (Breakpoints) + self.debugger = DebuggerManager(self) + self.panels.register(DebuggerPanel()) + # Update breakpoints if the number of lines in the file changes + self.blockCountChanged.connect(self.sig_breakpoints_changed) + + # Line number area management + self.linenumberarea = self.panels.register(LineNumberArea()) + + # Class and Method/Function Dropdowns + self.classfuncdropdown = self.panels.register( + ClassFunctionDropdown(), + Panel.Position.TOP, + ) + + # Colors to be defined in _apply_highlighter_color_scheme() + # Currentcell color and current line color are defined in base.py + self.occurrence_color = None + self.ctrl_click_color = None + self.sideareas_color = None + self.matched_p_color = None + self.unmatched_p_color = None + self.normal_color = None + self.comment_color = None + + # --- Syntax highlight entrypoint --- + # + # - if set, self.highlighter is responsible for + # - coloring raw text data inside editor on load + # - coloring text data when editor is cloned + # - updating document highlight on line edits + # - providing color palette (scheme) for the editor + # - providing data for Outliner + # - self.highlighter is not responsible for + # - background highlight for current line + # - background highlight for search / current line occurrences + + self.highlighter_class = sh.TextSH + self.highlighter = None + ccs = 'Spyder' + if ccs not in sh.COLOR_SCHEME_NAMES: + ccs = sh.COLOR_SCHEME_NAMES[0] + self.color_scheme = ccs + + self.highlight_current_line_enabled = False + + # Vertical scrollbar + # This is required to avoid a "RuntimeError: no access to protected + # functions or signals for objects not created from Python" in + # Linux Ubuntu. See spyder-ide/spyder#5215. + self.setVerticalScrollBar(QScrollBar()) + + # Highlights and flag colors + self.warning_color = SpyderPalette.COLOR_WARN_2 + self.error_color = SpyderPalette.COLOR_ERROR_1 + self.todo_color = SpyderPalette.GROUP_9 + self.breakpoint_color = SpyderPalette.ICON_3 + self.occurrence_color = QColor(SpyderPalette.GROUP_2).lighter(160) + self.found_results_color = QColor(SpyderPalette.COLOR_OCCURRENCE_4) + + # Scrollbar flag area + self.scrollflagarea = self.panels.register(ScrollFlagArea(), + Panel.Position.RIGHT) + self.panels.refresh() + + self.document_id = id(self) + + # Indicate occurrences of the selected word + self.cursorPositionChanged.connect(self.__cursor_position_changed) + self.__find_first_pos = None + + self.language = None + self.supported_language = False + self.supported_cell_language = False + self.comment_string = None + self._kill_ring = QtKillRing(self) + + # Block user data + self.blockCountChanged.connect(self.update_bookmarks) + + # Highlight using Pygments highlighter timer + # --------------------------------------------------------------------- + # For files that use the PygmentsSH we parse the full file inside + # the highlighter in order to generate the correct coloring. + self.timer_syntax_highlight = QTimer(self) + self.timer_syntax_highlight.setSingleShot(True) + self.timer_syntax_highlight.timeout.connect( + self.run_pygments_highlighter) + + # Mark occurrences timer + self.occurrence_highlighting = None + self.occurrence_timer = QTimer(self) + self.occurrence_timer.setSingleShot(True) + self.occurrence_timer.setInterval(1500) + self.occurrence_timer.timeout.connect(self.__mark_occurrences) + self.occurrences = [] + + # Update decorations + self.update_decorations_timer = QTimer(self) + self.update_decorations_timer.setSingleShot(True) + self.update_decorations_timer.setInterval( + self.UPDATE_DECORATIONS_TIMEOUT) + self.update_decorations_timer.timeout.connect( + self.update_decorations) + self.verticalScrollBar().valueChanged.connect( + lambda value: self.update_decorations_timer.start()) + + # LSP + self.textChanged.connect(self.schedule_document_did_change) + self._pending_server_requests = [] + self._server_requests_timer = QTimer(self) + self._server_requests_timer.setSingleShot(True) + self._server_requests_timer.setInterval(100) + self._server_requests_timer.timeout.connect( + self.process_server_requests) + + # Mark found results + self.textChanged.connect(self.__text_has_changed) + self.found_results = [] + + # Docstring + self.writer_docstring = DocstringWriterExtension(self) + + # Context menu + self.gotodef_action = None + self.setup_context_menu() + + # Tab key behavior + self.tab_indents = None + self.tab_mode = True # see CodeEditor.set_tab_mode + + # Intelligent backspace mode + self.intelligent_backspace = True + + # Automatic (on the fly) completions + self.automatic_completions = True + self.automatic_completions_after_chars = 3 + self.automatic_completions_after_ms = 300 + + # Code Folding + self.code_folding = True + self.update_folding_thread = QThread(None) + self.update_folding_thread.finished.connect(self.finish_code_folding) + + # Completions hint + self.completions_hint = True + self.completions_hint_after_ms = 500 + + self.close_parentheses_enabled = True + self.close_quotes_enabled = False + self.add_colons_enabled = True + self.auto_unindent_enabled = True + + # Autoformat on save + self.format_on_save = False + self.format_eventloop = QEventLoop(None) + self.format_timer = QTimer(self) + + # Mouse tracking + self.setMouseTracking(True) + self.__cursor_changed = False + self._mouse_left_button_pressed = False + self.ctrl_click_color = QColor(Qt.blue) + + self._bookmarks_blocks = {} + self.bookmarks = [] + + # Keyboard shortcuts + self.shortcuts = self.create_shortcuts() + + # Paint event + self.__visible_blocks = [] # Visible blocks, update with repaint + self.painted.connect(self._draw_editor_cell_divider) + + # Outline explorer + self.oe_proxy = None + + # Line stripping + self.last_change_position = None + self.last_position = None + self.last_auto_indent = None + self.skip_rstrip = False + self.strip_trailing_spaces_on_modify = True + + # Hover hints + self.hover_hints_enabled = None + + # Language Server + self.filename = None + self.completions_available = False + self.text_version = 0 + self.save_include_text = True + self.open_close_notifications = True + self.sync_mode = TextDocumentSyncKind.FULL + self.will_save_notify = False + self.will_save_until_notify = False + self.enable_hover = True + self.auto_completion_characters = [] + self.resolve_completions_enabled = False + self.signature_completion_characters = [] + self.go_to_definition_enabled = False + self.find_references_enabled = False + self.highlight_enabled = False + self.formatting_enabled = False + self.range_formatting_enabled = False + self.document_symbols_enabled = False + self.formatting_characters = [] + self.completion_args = None + self.folding_supported = False + self.is_cloned = False + self.operation_in_progress = False + self.formatting_in_progress = False + + # Diagnostics + self.update_diagnostics_thread = QThread(None) + self.update_diagnostics_thread.run = self.set_errors + self.update_diagnostics_thread.finished.connect( + self.finish_code_analysis) + self._diagnostics = [] + + # Editor Extensions + self.editor_extensions = EditorExtensionsManager(self) + self.editor_extensions.add(CloseQuotesExtension()) + self.editor_extensions.add(SnippetsExtension()) + self.editor_extensions.add(CloseBracketsExtension()) + + # Text diffs across versions + self.differ = diff_match_patch() + self.previous_text = '' + self.patch = [] + self.leading_whitespaces = {} + + # re-use parent of completion_widget (usually the main window) + completion_parent = self.completion_widget.parent() + self.kite_call_to_action = KiteCallToAction(self, completion_parent) + + # Some events should not be triggered during undo/redo + # such as line stripping + self.is_undoing = False + self.is_redoing = False + + # Timer to Avoid too many calls to rehighlight. + self._rehighlight_timer = QTimer(self) + self._rehighlight_timer.setSingleShot(True) + self._rehighlight_timer.setInterval(150) + + # --- Helper private methods + # ------------------------------------------------------------------------ + def process_server_requests(self): + """Process server requests.""" + # Check if document needs to be updated: + if self._document_server_needs_update: + self.document_did_change() + self._document_server_needs_update = False + for method, params, requires_response in self._pending_server_requests: + self.emit_request(method, params, requires_response) + self._pending_server_requests = [] + + # --- Hover/Hints + def _should_display_hover(self, point): + """Check if a hover hint should be displayed:""" + if not self._mouse_left_button_pressed: + return (self.hover_hints_enabled and point + and self.get_word_at(point)) + + def _handle_hover(self): + """Handle hover hint trigger after delay.""" + self._timer_mouse_moving.stop() + pos = self._last_point + + # These are textual characters but should not trigger a completion + # FIXME: update per language + ignore_chars = ['(', ')', '.'] + + if self._should_display_hover(pos): + key, pattern_text, cursor = self.get_pattern_at(pos) + text = self.get_word_at(pos) + if pattern_text: + ctrl_text = 'Cmd' if sys.platform == "darwin" else 'Ctrl' + if key in ['file']: + hint_text = ctrl_text + ' + ' + _('click to open file') + elif key in ['mail']: + hint_text = ctrl_text + ' + ' + _('click to send email') + elif key in ['url']: + hint_text = ctrl_text + ' + ' + _('click to open url') + else: + hint_text = ctrl_text + ' + ' + _('click to open') + + hint_text = ' {} '.format(hint_text) + + self.show_tooltip(text=hint_text, at_point=pos) + return + + cursor = self.cursorForPosition(pos) + cursor_offset = cursor.position() + line, col = cursor.blockNumber(), cursor.columnNumber() + self._last_point = pos + if text and self._last_hover_word != text: + if all(char not in text for char in ignore_chars): + self._last_hover_word = text + self.request_hover(line, col, cursor_offset) + else: + self.hide_tooltip() + elif not self.is_completion_widget_visible(): + self.hide_tooltip() + + def blockuserdata_list(self): + """Get the list of all user data in document.""" + block = self.document().firstBlock() + while block.isValid(): + data = block.userData() + if data: + yield data + block = block.next() + + def outlineexplorer_data_list(self): + """Get the list of all user data in document.""" + for data in self.blockuserdata_list(): + if data.oedata: + yield data.oedata + + # ---- Keyboard Shortcuts + + def create_cursor_callback(self, attr): + """Make a callback for cursor move event type, (e.g. "Start")""" + def cursor_move_event(): + cursor = self.textCursor() + move_type = getattr(QTextCursor, attr) + cursor.movePosition(move_type) + self.setTextCursor(cursor) + return cursor_move_event + + def create_shortcuts(self): + """Create the local shortcuts for the CodeEditor.""" + shortcut_context_name_callbacks = ( + ('editor', 'code completion', self.do_completion), + ('editor', 'duplicate line down', self.duplicate_line_down), + ('editor', 'duplicate line up', self.duplicate_line_up), + ('editor', 'delete line', self.delete_line), + ('editor', 'move line up', self.move_line_up), + ('editor', 'move line down', self.move_line_down), + ('editor', 'go to new line', self.go_to_new_line), + ('editor', 'go to definition', self.go_to_definition_from_cursor), + ('editor', 'toggle comment', self.toggle_comment), + ('editor', 'blockcomment', self.blockcomment), + ('editor', 'unblockcomment', self.unblockcomment), + ('editor', 'transform to uppercase', self.transform_to_uppercase), + ('editor', 'transform to lowercase', self.transform_to_lowercase), + ('editor', 'indent', lambda: self.indent(force=True)), + ('editor', 'unindent', lambda: self.unindent(force=True)), + ('editor', 'start of line', + self.create_cursor_callback('StartOfLine')), + ('editor', 'end of line', + self.create_cursor_callback('EndOfLine')), + ('editor', 'previous line', self.create_cursor_callback('Up')), + ('editor', 'next line', self.create_cursor_callback('Down')), + ('editor', 'previous char', self.create_cursor_callback('Left')), + ('editor', 'next char', self.create_cursor_callback('Right')), + ('editor', 'previous word', + self.create_cursor_callback('PreviousWord')), + ('editor', 'next word', self.create_cursor_callback('NextWord')), + ('editor', 'kill to line end', self.kill_line_end), + ('editor', 'kill to line start', self.kill_line_start), + ('editor', 'yank', self._kill_ring.yank), + ('editor', 'rotate kill ring', self._kill_ring.rotate), + ('editor', 'kill previous word', self.kill_prev_word), + ('editor', 'kill next word', self.kill_next_word), + ('editor', 'start of document', + self.create_cursor_callback('Start')), + ('editor', 'end of document', + self.create_cursor_callback('End')), + ('editor', 'undo', self.undo), + ('editor', 'redo', self.redo), + ('editor', 'cut', self.cut), + ('editor', 'copy', self.copy), + ('editor', 'paste', self.paste), + ('editor', 'delete', self.delete), + ('editor', 'select all', self.selectAll), + ('editor', 'docstring', + self.writer_docstring.write_docstring_for_shortcut), + ('editor', 'autoformatting', self.format_document_or_range), + ('array_builder', 'enter array inline', self.enter_array_inline), + ('array_builder', 'enter array table', self.enter_array_table) + ) + + shortcuts = [] + for context, name, callback in shortcut_context_name_callbacks: + shortcuts.append( + CONF.config_shortcut( + callback, context=context, name=name, parent=self)) + return shortcuts + + def get_shortcut_data(self): + """ + Returns shortcut data, a list of tuples (shortcut, text, default) + shortcut (QShortcut or QAction instance) + text (string): action/shortcut description + default (string): default key sequence + """ + return [sc.data for sc in self.shortcuts] + + def closeEvent(self, event): + if isinstance(self.highlighter, sh.PygmentsSH): + self.highlighter.stop() + self.update_folding_thread.quit() + self.update_folding_thread.wait() + self.update_diagnostics_thread.quit() + self.update_diagnostics_thread.wait() + TextEditBaseWidget.closeEvent(self, event) + + def get_document_id(self): + return self.document_id + + def set_as_clone(self, editor): + """Set as clone editor""" + self.setDocument(editor.document()) + self.document_id = editor.get_document_id() + self.highlighter = editor.highlighter + self._rehighlight_timer.timeout.connect( + self.highlighter.rehighlight) + self.eol_chars = editor.eol_chars + self._apply_highlighter_color_scheme() + self.highlighter.sig_font_changed.connect(self.sync_font) + + # ---- Widget setup and options + def toggle_wrap_mode(self, enable): + """Enable/disable wrap mode""" + self.set_wrap_mode('word' if enable else None) + + def toggle_line_numbers(self, linenumbers=True, markers=False): + """Enable/disable line numbers.""" + self.linenumberarea.setup_margins(linenumbers, markers) + + @property + def panels(self): + """ + Returns a reference to the + :class:`spyder.widgets.panels.managers.PanelsManager` + used to manage the collection of installed panels + """ + return self._panels + + def setup_editor(self, + linenumbers=True, + language=None, + markers=False, + font=None, + color_scheme=None, + wrap=False, + tab_mode=True, + strip_mode=False, + intelligent_backspace=True, + automatic_completions=True, + automatic_completions_after_chars=3, + automatic_completions_after_ms=300, + completions_hint=True, + completions_hint_after_ms=500, + hover_hints=True, + code_snippets=True, + highlight_current_line=True, + highlight_current_cell=True, + occurrence_highlighting=True, + scrollflagarea=True, + edge_line=True, + edge_line_columns=(79,), + show_blanks=False, + underline_errors=False, + close_parentheses=True, + close_quotes=False, + add_colons=True, + auto_unindent=True, + indent_chars=" "*4, + tab_stop_width_spaces=4, + cloned_from=None, + filename=None, + occurrence_timeout=1500, + show_class_func_dropdown=False, + indent_guides=False, + scroll_past_end=False, + show_debug_panel=True, + folding=True, + remove_trailing_spaces=False, + remove_trailing_newlines=False, + add_newline=False, + format_on_save=False): + """ + Set-up configuration for the CodeEditor instance. + + Usually the parameters here are related with a configurable preference + in the Preference Dialog and Editor configurations: + + linenumbers: Enable/Disable line number panel. Default True. + language: Set editor language for example python. Default None. + markers: Enable/Disable markers panel. Used to show elements like + Code Analysis. Default False. + font: Base font for the Editor to use. Default None. + color_scheme: Initial color scheme for the Editor to use. Default None. + wrap: Enable/Disable line wrap. Default False. + tab_mode: Enable/Disable using Tab as delimiter between word, + Default True. + strip_mode: strip_mode: Enable/Disable striping trailing spaces when + modifying the file. Default False. + intelligent_backspace: Enable/Disable automatically unindenting + inserted text (unindenting happens if the leading text length of + the line isn't module of the length of indentation chars being use) + Default True. + automatic_completions: Enable/Disable automatic completions. + The behavior of the trigger of this the completions can be + established with the two following kwargs. Default True. + automatic_completions_after_chars: Number of charts to type to trigger + an automatic completion. Default 3. + automatic_completions_after_ms: Number of milliseconds to pass before + an autocompletion is triggered. Default 300. + completions_hint: Enable/Disable documentation hints for completions. + Default True. + completions_hint_after_ms: Number of milliseconds over a completion + item to show the documentation hint. Default 500. + hover_hints: Enable/Disable documentation hover hints. Default True. + code_snippets: Enable/Disable code snippets completions. Default True. + highlight_current_line: Enable/Disable current line highlighting. + Default True. + highlight_current_cell: Enable/Disable current cell highlighting. + Default True. + occurrence_highlighting: Enable/Disable highlighting of current word + occurrence in the file. Default True. + scrollflagarea : Enable/Disable flag area that shows at the left of + the scroll bar. Default True. + edge_line: Enable/Disable vertical line to show max number of + characters per line. Customizable number of columns in the + following kwarg. Default True. + edge_line_columns: Number of columns/characters where the editor + horizontal edge line will show. Default (79,). + show_blanks: Enable/Disable blanks highlighting. Default False. + underline_errors: Enable/Disable showing and underline to highlight + errors. Default False. + close_parentheses: Enable/Disable automatic parentheses closing + insertion. Default True. + close_quotes: Enable/Disable automatic closing of quotes. + Default False. + add_colons: Enable/Disable automatic addition of colons. Default True. + auto_unindent: Enable/Disable automatically unindentation before else, + elif, finally or except statements. Default True. + indent_chars: Characters to use for indentation. Default " "*4. + tab_stop_width_spaces: Enable/Disable using tabs for indentation. + Default 4. + cloned_from: Editor instance used as template to instantiate this + CodeEditor instance. Default None. + filename: Initial filename to show. Default None. + occurrence_timeout : Timeout in milliseconds to start highlighting + matches/occurrences for the current word under the cursor. + Default 1500 ms. + show_class_func_dropdown: Enable/Disable a Matlab like widget to show + classes and functions available in the current file. Default False. + indent_guides: Enable/Disable highlighting of code indentation. + Default False. + scroll_past_end: Enable/Disable possibility to scroll file passed + its end. Default False. + show_debug_panel: Enable/Disable debug panel. Default True. + folding: Enable/Disable code folding. Default True. + remove_trailing_spaces: Remove trailing whitespaces on lines. + Default False. + remove_trailing_newlines: Remove extra lines at the end of the file. + Default False. + add_newline: Add a newline at the end of the file if there is not one. + Default False. + format_on_save: Autoformat file automatically when saving. + Default False. + """ + + self.set_close_parentheses_enabled(close_parentheses) + self.set_close_quotes_enabled(close_quotes) + self.set_add_colons_enabled(add_colons) + self.set_auto_unindent_enabled(auto_unindent) + self.set_indent_chars(indent_chars) + + # Show/hide the debug panel depending on the language and parameter + self.set_debug_panel(show_debug_panel, language) + + # Show/hide folding panel depending on parameter + self.toggle_code_folding(folding) + + # Scrollbar flag area + self.scrollflagarea.set_enabled(scrollflagarea) + + # Debugging + self.debugger.set_filename(filename) + + # Edge line + self.edge_line.set_enabled(edge_line) + self.edge_line.set_columns(edge_line_columns) + + # Indent guides + self.toggle_identation_guides(indent_guides) + if self.indent_chars == '\t': + self.indent_guides.set_indentation_width( + tab_stop_width_spaces) + else: + self.indent_guides.set_indentation_width(len(self.indent_chars)) + + # Blanks + self.set_blanks_enabled(show_blanks) + + # Remove trailing whitespaces + self.set_remove_trailing_spaces(remove_trailing_spaces) + + # Remove trailing newlines + self.set_remove_trailing_newlines(remove_trailing_newlines) + + # Add newline at the end + self.set_add_newline(add_newline) + + # Scrolling past the end + self.set_scrollpastend_enabled(scroll_past_end) + + # Line number area and indent guides + if cloned_from: + self.setFont(font) # this is required for line numbers area + # Needed to show indent guides for splited editor panels + # See spyder-ide/spyder#10900 + self.patch = cloned_from.patch + self.is_cloned = True + self.toggle_line_numbers(linenumbers, markers) + + # Lexer + self.filename = filename + self.set_language(language, filename) + + # Underline errors and warnings + self.set_underline_errors_enabled(underline_errors) + + # Highlight current cell + self.set_highlight_current_cell(highlight_current_cell) + + # Highlight current line + self.set_highlight_current_line(highlight_current_line) + + # Occurrence highlighting + self.set_occurrence_highlighting(occurrence_highlighting) + self.set_occurrence_timeout(occurrence_timeout) + + # Tab always indents (even when cursor is not at the begin of line) + self.set_tab_mode(tab_mode) + + # Intelligent backspace + self.toggle_intelligent_backspace(intelligent_backspace) + + # Automatic completions + self.toggle_automatic_completions(automatic_completions) + self.set_automatic_completions_after_chars( + automatic_completions_after_chars) + self.set_automatic_completions_after_ms(automatic_completions_after_ms) + + # Completions hint + self.toggle_completions_hint(completions_hint) + self.set_completions_hint_after_ms(completions_hint_after_ms) + + # Hover hints + self.toggle_hover_hints(hover_hints) + + # Code snippets + self.toggle_code_snippets(code_snippets) + + # Autoformat on save + self.toggle_format_on_save(format_on_save) + + if cloned_from is not None: + self.set_as_clone(cloned_from) + self.panels.refresh() + elif font is not None: + self.set_font(font, color_scheme) + elif color_scheme is not None: + self.set_color_scheme(color_scheme) + + # Set tab spacing after font is set + self.set_tab_stop_width_spaces(tab_stop_width_spaces) + + self.toggle_wrap_mode(wrap) + + # Class/Function dropdown will be disabled if we're not in a Python + # file. + self.classfuncdropdown.setVisible(show_class_func_dropdown + and self.is_python_like()) + + self.set_strip_mode(strip_mode) + + # --- Language Server Protocol methods ----------------------------------- + # ------------------------------------------------------------------------ + @Slot(str, dict) + def handle_response(self, method, params): + if method in self.handler_registry: + handler_name = self.handler_registry[method] + handler = getattr(self, handler_name) + handler(params) + # This signal is only used on tests. + # It could be used to track and profile LSP diagnostics. + self.completions_response_signal.emit(method, params) + + def emit_request(self, method, params, requires_response): + """Send request to LSP manager.""" + params['requires_response'] = requires_response + params['response_instance'] = self + self.sig_perform_completion_request.emit( + self.language.lower(), method, params) + + def log_lsp_handle_errors(self, message): + """ + Log errors when handling LSP responses. + + This works when debugging is on or off. + """ + if get_debug_level() > 0: + # We log the error normally when running on debug mode. + logger.error(message, exc_info=True) + else: + # We need this because logger.error activates our error + # report dialog but it doesn't show the entire traceback + # there. So we intentionally leave an error in this call + # to get the entire stack info generated by it, which + # gives the info we need from users. + if PY2: + logger.error(message, exc_info=True) + print(message, file=sys.stderr) + else: + logger.error('%', 1, stack_info=True) + + # ------------- LSP: Configuration and protocol start/end ---------------- + def start_completion_services(self): + """Start completion services for this instance.""" + self.completions_available = True + + if self.is_cloned: + additional_msg = " cloned editor" + else: + additional_msg = "" + self.document_did_open() + + logger.debug(u"Completion services available for {0}: {1}".format( + additional_msg, self.filename)) + + def register_completion_capabilities(self, capabilities): + """ + Register completion server capabilities. + + Parameters + ---------- + capabilities: dict + Capabilities supported by a language server. + """ + sync_options = capabilities['textDocumentSync'] + completion_options = capabilities['completionProvider'] + signature_options = capabilities['signatureHelpProvider'] + range_formatting_options = ( + capabilities['documentOnTypeFormattingProvider']) + self.open_close_notifications = sync_options.get('openClose', False) + self.sync_mode = sync_options.get('change', TextDocumentSyncKind.NONE) + self.will_save_notify = sync_options.get('willSave', False) + self.will_save_until_notify = sync_options.get('willSaveWaitUntil', + False) + self.save_include_text = sync_options['save']['includeText'] + self.enable_hover = capabilities['hoverProvider'] + self.folding_supported = capabilities.get( + 'foldingRangeProvider', False) + self.auto_completion_characters = ( + completion_options['triggerCharacters']) + self.resolve_completions_enabled = ( + completion_options.get('resolveProvider', False)) + self.signature_completion_characters = ( + signature_options['triggerCharacters'] + ['=']) # FIXME: + self.go_to_definition_enabled = capabilities['definitionProvider'] + self.find_references_enabled = capabilities['referencesProvider'] + self.highlight_enabled = capabilities['documentHighlightProvider'] + self.formatting_enabled = capabilities['documentFormattingProvider'] + self.range_formatting_enabled = ( + capabilities['documentRangeFormattingProvider']) + self.document_symbols_enabled = ( + capabilities['documentSymbolProvider'] + ) + self.formatting_characters.append( + range_formatting_options['firstTriggerCharacter']) + self.formatting_characters += ( + range_formatting_options.get('moreTriggerCharacter', [])) + + if self.formatting_enabled: + self.format_action.setEnabled(True) + self.sig_refresh_formatting.emit(True) + + self.completions_available = True + + def stop_completion_services(self): + logger.debug('Stopping completion services for %s' % self.filename) + self.completions_available = False + + @schedule_request(method=CompletionRequestTypes.DOCUMENT_DID_OPEN, + requires_response=False) + def document_did_open(self): + """Send textDocument/didOpen request to the server.""" + cursor = self.textCursor() + text = self.get_text_with_eol() + if self.is_ipython(): + # Send valid python text to LSP as it doesn't support IPython + text = self.ipython_to_python(text) + params = { + 'file': self.filename, + 'language': self.language, + 'version': self.text_version, + 'text': text, + 'codeeditor': self, + 'offset': cursor.position(), + 'selection_start': cursor.selectionStart(), + 'selection_end': cursor.selectionEnd(), + } + return params + + # ------------- LSP: Symbols --------------------------------------- + @schedule_request(method=CompletionRequestTypes.DOCUMENT_SYMBOL) + def request_symbols(self): + """Request document symbols.""" + if not self.document_symbols_enabled: + return + if self.oe_proxy is not None: + self.oe_proxy.emit_request_in_progress() + params = {'file': self.filename} + return params + + @handles(CompletionRequestTypes.DOCUMENT_SYMBOL) + def process_symbols(self, params): + """Handle symbols response.""" + try: + symbols = params['params'] + symbols = [] if symbols is None else symbols + self.classfuncdropdown.update_data(symbols) + if self.oe_proxy is not None: + self.oe_proxy.update_outline_info(symbols) + except RuntimeError: + # This is triggered when a codeeditor instance was removed + # before the response can be processed. + return + except Exception: + self.log_lsp_handle_errors("Error when processing symbols") + + # ------------- LSP: Linting --------------------------------------- + def schedule_document_did_change(self): + """Schedule a document update.""" + self._document_server_needs_update = True + self._server_requests_timer.start() + + @request( + method=CompletionRequestTypes.DOCUMENT_DID_CHANGE, + requires_response=False) + def document_did_change(self): + """Send textDocument/didChange request to the server.""" + # Cancel formatting + self.formatting_in_progress = False + text = self.get_text_with_eol() + if self.is_ipython(): + # Send valid python text to LSP + text = self.ipython_to_python(text) + + self.text_version += 1 + + self.patch = self.differ.patch_make(self.previous_text, text) + self.previous_text = text + cursor = self.textCursor() + params = { + 'file': self.filename, + 'version': self.text_version, + 'text': text, + 'diff': self.patch, + 'offset': cursor.position(), + 'selection_start': cursor.selectionStart(), + 'selection_end': cursor.selectionEnd(), + } + return params + + @handles(CompletionRequestTypes.DOCUMENT_PUBLISH_DIAGNOSTICS) + def process_diagnostics(self, params): + """Handle linting response.""" + # The LSP spec doesn't require that folding and symbols + # are treated in the same way as linting, i.e. to be + # recomputed on didChange, didOpen and didSave. However, + # we think that's necessary to maintain accurate folding + # and symbols all the time. Therefore, we decided to call + # those requests here, but after a certain timeout to + # avoid performance issues. + self._timer_sync_symbols_and_folding.start() + + # Process results (runs in a thread) + self.process_code_analysis(params['params']) + + def set_sync_symbols_and_folding_timeout(self): + """ + Set timeout to sync symbols and folding according to the file + size. + """ + current_lines = self.get_line_count() + timeout = None + + for lines in self.SYNC_SYMBOLS_AND_FOLDING_TIMEOUTS.keys(): + if (current_lines // lines) == 0: + timeout = self.SYNC_SYMBOLS_AND_FOLDING_TIMEOUTS[lines] + break + + if not timeout: + timeouts = self.SYNC_SYMBOLS_AND_FOLDING_TIMEOUTS.values() + timeout = list(timeouts)[-1] + + self._timer_sync_symbols_and_folding.setInterval(timeout) + + def sync_symbols_and_folding(self): + """ + Synchronize symbols and folding after linting results arrive. + """ + self.request_folding() + self.request_symbols() + + def process_code_analysis(self, diagnostics): + """Process code analysis results in a thread.""" + self.cleanup_code_analysis() + self._diagnostics = diagnostics + + # Process diagnostics in a thread to improve performance. + self.update_diagnostics_thread.start() + + def cleanup_code_analysis(self): + """Remove all code analysis markers""" + self.setUpdatesEnabled(False) + self.clear_extra_selections('code_analysis_highlight') + self.clear_extra_selections('code_analysis_underline') + for data in self.blockuserdata_list(): + data.code_analysis = [] + + self.setUpdatesEnabled(True) + # When the new code analysis results are empty, it is necessary + # to update manually the scrollflag and linenumber areas (otherwise, + # the old flags will still be displayed): + self.sig_flags_changed.emit() + self.linenumberarea.update() + + def set_errors(self): + """Set errors and warnings in the line number area.""" + try: + self._process_code_analysis(underline=False) + except RuntimeError: + # This is triggered when a codeeditor instance was removed + # before the response can be processed. + return + except Exception: + self.log_lsp_handle_errors("Error when processing linting") + + def underline_errors(self): + """Underline errors and warnings.""" + try: + # Clear current selections before painting the new ones. + # This prevents accumulating them when moving around in or editing + # the file, which generated a memory leakage and sluggishness + # after some time. + self.clear_extra_selections('code_analysis_underline') + self._process_code_analysis(underline=True) + except RuntimeError: + # This is triggered when a codeeditor instance was removed + # before the response can be processed. + return + except Exception: + self.log_lsp_handle_errors("Error when processing linting") + + def finish_code_analysis(self): + """Finish processing code analysis results.""" + self.linenumberarea.update() + if self.underline_errors_enabled: + self.underline_errors() + self.sig_process_code_analysis.emit() + self.sig_flags_changed.emit() + + def errors_present(self): + """ + Return True if there are errors or warnings present in the file. + """ + return bool(len(self._diagnostics)) + + def _process_code_analysis(self, underline): + """ + Process all code analysis results. + + Parameters + ---------- + underline: bool + Determines if errors and warnings are going to be set in + the line number area or underlined. It's better to separate + these two processes for perfomance reasons. That's because + setting errors can be done in a thread whereas underlining + them can't. + """ + document = self.document() + if underline: + first_block, last_block = self.get_buffer_block_numbers() + + for diagnostic in self._diagnostics: + if self.is_ipython() and ( + diagnostic["message"] == "undefined name 'get_ipython'"): + # get_ipython is defined in IPython files + continue + source = diagnostic.get('source', '') + msg_range = diagnostic['range'] + start = msg_range['start'] + end = msg_range['end'] + code = diagnostic.get('code', 'E') + message = diagnostic['message'] + severity = diagnostic.get( + 'severity', DiagnosticSeverity.ERROR) + + block = document.findBlockByNumber(start['line']) + text = block.text() + + # Skip messages according to certain criteria. + # This one works for any programming language + if 'analysis:ignore' in text: + continue + + # This only works for Python. + if self.language == 'Python': + if NOQA_INLINE_REGEXP.search(text) is not None: + continue + + data = block.userData() + if not data: + data = BlockUserData(self) + + if underline: + block_nb = block.blockNumber() + if first_block <= block_nb <= last_block: + error = severity == DiagnosticSeverity.ERROR + color = self.error_color if error else self.warning_color + color = QColor(color) + color.setAlpha(255) + block.color = color + + data.selection_start = start + data.selection_end = end + + self.highlight_selection('code_analysis_underline', + data._selection(), + underline_color=block.color) + else: + # Don't append messages to data for cloned editors to avoid + # showing them twice or more times on hover. + # Fixes spyder-ide/spyder#15618 + if not self.is_cloned: + data.code_analysis.append( + (source, code, severity, message) + ) + block.setUserData(data) + + # ------------- LSP: Completion --------------------------------------- + @schedule_request(method=CompletionRequestTypes.DOCUMENT_COMPLETION) + def do_completion(self, automatic=False): + """Trigger completion.""" + cursor = self.textCursor() + current_word = self.get_current_word( + completion=True, + valid_python_variable=False + ) + + params = { + 'file': self.filename, + 'line': cursor.blockNumber(), + 'column': cursor.columnNumber(), + 'offset': cursor.position(), + 'selection_start': cursor.selectionStart(), + 'selection_end': cursor.selectionEnd(), + 'current_word': current_word + } + self.completion_args = (self.textCursor().position(), automatic) + return params + + @handles(CompletionRequestTypes.DOCUMENT_COMPLETION) + def process_completion(self, params): + """Handle completion response.""" + args = self.completion_args + if args is None: + # This should not happen + return + self.completion_args = None + position, automatic = args + + start_cursor = self.textCursor() + start_cursor.movePosition(QTextCursor.StartOfBlock) + line_text = self.get_text(start_cursor.position(), 'eol') + leading_whitespace = self.compute_whitespace(line_text) + indentation_whitespace = ' ' * leading_whitespace + eol_char = self.get_line_separator() + + try: + completions = params['params'] + completions = ([] if completions is None else + [completion for completion in completions + if completion.get('insertText') + or completion.get('textEdit', {}).get('newText')]) + prefix = self.get_current_word(completion=True, + valid_python_variable=False) + if (len(completions) == 1 + and completions[0].get('insertText') == prefix + and not completions[0].get('textEdit', {}).get('newText')): + completions.pop() + + replace_end = self.textCursor().position() + under_cursor = self.get_current_word_and_position(completion=True) + if under_cursor: + word, replace_start = under_cursor + else: + word = '' + replace_start = replace_end + first_letter = '' + if len(word) > 0: + first_letter = word[0] + + def sort_key(completion): + if 'textEdit' in completion: + text_insertion = completion['textEdit']['newText'] + else: + text_insertion = completion['insertText'] + first_insert_letter = text_insertion[0] + case_mismatch = ( + (first_letter.isupper() and first_insert_letter.islower()) + or + (first_letter.islower() and first_insert_letter.isupper()) + ) + # False < True, so case matches go first + return (case_mismatch, completion['sortText']) + + completion_list = sorted(completions, key=sort_key) + + # Allow for textEdit completions to be filtered by Spyder + # if on-the-fly completions are disabled, only if the + # textEdit range matches the word under the cursor. + for completion in completion_list: + if 'textEdit' in completion: + c_replace_start = completion['textEdit']['range']['start'] + c_replace_end = completion['textEdit']['range']['end'] + if (c_replace_start == replace_start + and c_replace_end == replace_end): + insert_text = completion['textEdit']['newText'] + completion['filterText'] = insert_text + completion['insertText'] = insert_text + del completion['textEdit'] + + if 'insertText' in completion: + insert_text = completion['insertText'] + insert_text_lines = insert_text.splitlines() + reindented_text = [insert_text_lines[0]] + for insert_line in insert_text_lines[1:]: + insert_line = indentation_whitespace + insert_line + reindented_text.append(insert_line) + reindented_text = eol_char.join(reindented_text) + completion['insertText'] = reindented_text + + self.completion_widget.show_list( + completion_list, position, automatic) + + self.kite_call_to_action.handle_processed_completions(completions) + except RuntimeError: + # This is triggered when a codeeditor instance was removed + # before the response can be processed. + self.kite_call_to_action.hide_coverage_cta() + return + except Exception: + self.log_lsp_handle_errors('Error when processing completions') + + @schedule_request(method=CompletionRequestTypes.COMPLETION_RESOLVE) + def resolve_completion_item(self, item): + return { + 'file': self.filename, + 'completion_item': item + } + + @handles(CompletionRequestTypes.COMPLETION_RESOLVE) + def handle_completion_item_resolution(self, response): + try: + response = response['params'] + + if not response: + return + + self.completion_widget.augment_completion_info(response) + except RuntimeError: + # This is triggered when a codeeditor instance was removed + # before the response can be processed. + return + except Exception: + self.log_lsp_handle_errors( + "Error when handling completion item resolution") + + # ------------- LSP: Signature Hints ------------------------------------ + @schedule_request(method=CompletionRequestTypes.DOCUMENT_SIGNATURE) + def request_signature(self): + """Ask for signature.""" + line, column = self.get_cursor_line_column() + offset = self.get_position('cursor') + params = { + 'file': self.filename, + 'line': line, + 'column': column, + 'offset': offset + } + return params + + @handles(CompletionRequestTypes.DOCUMENT_SIGNATURE) + def process_signatures(self, params): + """Handle signature response.""" + try: + signature_params = params['params'] + + if (signature_params is not None and + 'activeParameter' in signature_params): + self.sig_signature_invoked.emit(signature_params) + signature_data = signature_params['signatures'] + documentation = signature_data['documentation'] + + if isinstance(documentation, dict): + documentation = documentation['value'] + + # The language server returns encoded text with + # spaces defined as `\xa0` + documentation = documentation.replace(u'\xa0', ' ') + + parameter_idx = signature_params['activeParameter'] + parameters = signature_data['parameters'] + parameter = None + if len(parameters) > 0 and parameter_idx < len(parameters): + parameter_data = parameters[parameter_idx] + parameter = parameter_data['label'] + + signature = signature_data['label'] + + # This method is part of spyder/widgets/mixins + self.show_calltip( + signature=signature, + parameter=parameter, + language=self.language, + documentation=documentation, + ) + except RuntimeError: + # This is triggered when a codeeditor instance was removed + # before the response can be processed. + return + except Exception: + self.log_lsp_handle_errors("Error when processing signature") + + # ------------- LSP: Hover/Mouse --------------------------------------- + @schedule_request(method=CompletionRequestTypes.DOCUMENT_CURSOR_EVENT) + def request_cursor_event(self): + text = self.get_text_with_eol() + cursor = self.textCursor() + params = { + 'file': self.filename, + 'version': self.text_version, + 'text': text, + 'offset': cursor.position(), + 'selection_start': cursor.selectionStart(), + 'selection_end': cursor.selectionEnd(), + } + return params + + @schedule_request(method=CompletionRequestTypes.DOCUMENT_HOVER) + def request_hover(self, line, col, offset, show_hint=True, clicked=True): + """Request hover information.""" + params = { + 'file': self.filename, + 'line': line, + 'column': col, + 'offset': offset + } + self._show_hint = show_hint + self._request_hover_clicked = clicked + return params + + @handles(CompletionRequestTypes.DOCUMENT_HOVER) + def handle_hover_response(self, contents): + """Handle hover response.""" + if running_under_pytest(): + from unittest.mock import Mock + + # On some tests this is returning a Mock + if isinstance(contents, Mock): + return + + try: + content = contents['params'] + + # - Don't display hover if there's no content to display. + # - Prevent spurious errors when a client returns a list. + if not content or isinstance(content, list): + return + + self.sig_display_object_info.emit( + content, + self._request_hover_clicked + ) + if content is not None and self._show_hint and self._last_point: + # This is located in spyder/widgets/mixins.py + word = self._last_hover_word + content = content.replace(u'\xa0', ' ') + self.show_hint(content, inspect_word=word, + at_point=self._last_point) + self._last_point = None + except RuntimeError: + # This is triggered when a codeeditor instance was removed + # before the response can be processed. + return + except Exception: + self.log_lsp_handle_errors("Error when processing hover") + + # ------------- LSP: Go To Definition ---------------------------- + @Slot() + @schedule_request(method=CompletionRequestTypes.DOCUMENT_DEFINITION) + def go_to_definition_from_cursor(self, cursor=None): + """Go to definition from cursor instance (QTextCursor).""" + if (not self.go_to_definition_enabled or + self.in_comment_or_string()): + return + + if cursor is None: + cursor = self.textCursor() + + text = to_text_string(cursor.selectedText()) + + if len(text) == 0: + cursor.select(QTextCursor.WordUnderCursor) + text = to_text_string(cursor.selectedText()) + + if text is not None: + line, column = self.get_cursor_line_column() + params = { + 'file': self.filename, + 'line': line, + 'column': column + } + return params + + @handles(CompletionRequestTypes.DOCUMENT_DEFINITION) + def handle_go_to_definition(self, position): + """Handle go to definition response.""" + try: + position = position['params'] + if position is not None: + def_range = position['range'] + start = def_range['start'] + if self.filename == position['file']: + self.go_to_line(start['line'] + 1, + start['character'], + None, + word=None) + else: + self.go_to_definition.emit(position['file'], + start['line'] + 1, + start['character']) + except RuntimeError: + # This is triggered when a codeeditor instance was removed + # before the response can be processed. + return + except Exception: + self.log_lsp_handle_errors( + "Error when processing go to definition") + + # ------------- LSP: Document/Selection formatting -------------------- + def format_document_or_range(self): + if self.has_selected_text() and self.range_formatting_enabled: + self.format_document_range() + else: + self.format_document() + + @schedule_request(method=CompletionRequestTypes.DOCUMENT_FORMATTING) + def format_document(self): + if not self.formatting_enabled: + return + if self.formatting_in_progress: + # Already waiting for a formatting + return + + using_spaces = self.indent_chars != '\t' + tab_size = (len(self.indent_chars) if using_spaces else + self.tab_stop_width_spaces) + params = { + 'file': self.filename, + 'options': { + 'tab_size': tab_size, + 'insert_spaces': using_spaces, + 'trim_trailing_whitespace': self.remove_trailing_spaces, + 'insert_final_new_line': self.add_newline, + 'trim_final_new_lines': self.remove_trailing_newlines + } + } + + # Sets the document into read-only and updates its corresponding + # tab name to display the filename into parenthesis + self.setReadOnly(True) + self.document().setModified(True) + self.sig_start_operation_in_progress.emit() + self.operation_in_progress = True + self.formatting_in_progress = True + + return params + + @schedule_request(method=CompletionRequestTypes.DOCUMENT_RANGE_FORMATTING) + def format_document_range(self): + if not self.range_formatting_enabled or not self.has_selected_text(): + return + if self.formatting_in_progress: + # Already waiting for a formatting + return + + start, end = self.get_selection_start_end() + start_line, start_col = start + end_line, end_col = end + using_spaces = self.indent_chars != '\t' + tab_size = (len(self.indent_chars) if using_spaces else + self.tab_stop_width_spaces) + + fmt_range = { + 'start': { + 'line': start_line, + 'character': start_col + }, + 'end': { + 'line': end_line, + 'character': end_col + } + } + params = { + 'file': self.filename, + 'range': fmt_range, + 'options': { + 'tab_size': tab_size, + 'insert_spaces': using_spaces, + 'trim_trailing_whitespace': self.remove_trailing_spaces, + 'insert_final_new_line': self.add_newline, + 'trim_final_new_lines': self.remove_trailing_newlines + } + } + + # Sets the document into read-only and updates its corresponding + # tab name to display the filename into parenthesis + self.setReadOnly(True) + self.document().setModified(True) + self.sig_start_operation_in_progress.emit() + self.operation_in_progress = True + self.formatting_in_progress = True + + return params + + @handles(CompletionRequestTypes.DOCUMENT_FORMATTING) + def handle_document_formatting(self, edits): + try: + if self.formatting_in_progress: + self._apply_document_edits(edits) + except RuntimeError: + # This is triggered when a codeeditor instance was removed + # before the response can be processed. + return + except Exception: + self.log_lsp_handle_errors("Error when processing document " + "formatting") + finally: + # Remove read-only parenthesis and highlight document modification + self.setReadOnly(False) + self.document().setModified(False) + self.document().setModified(True) + self.sig_stop_operation_in_progress.emit() + self.operation_in_progress = False + self.formatting_in_progress = False + + @handles(CompletionRequestTypes.DOCUMENT_RANGE_FORMATTING) + def handle_document_range_formatting(self, edits): + try: + if self.formatting_in_progress: + self._apply_document_edits(edits) + except RuntimeError: + # This is triggered when a codeeditor instance was removed + # before the response can be processed. + return + except Exception: + self.log_lsp_handle_errors("Error when processing document " + "selection formatting") + finally: + # Remove read-only parenthesis and highlight document modification + self.setReadOnly(False) + self.document().setModified(False) + self.document().setModified(True) + self.sig_stop_operation_in_progress.emit() + self.operation_in_progress = False + self.formatting_in_progress = False + + def _apply_document_edits(self, edits): + """Apply a set of atomic document edits to the current editor text.""" + edits = edits['params'] + if edits is None: + return + + # We need to use here toPlainText (which returns text with '\n' + # for eols) and not get_text_with_eol, so that applying the + # text edits that come from the LSP in the way implemented below + # works as expected. That's because we assume eol chars of length + # one in our algorithm. + # Fixes spyder-ide/spyder#16180 + text = self.toPlainText() + + text_tokens = list(text) + merged_text = None + for edit in edits: + edit_range = edit['range'] + repl_text = edit['newText'] + start, end = edit_range['start'], edit_range['end'] + start_line, start_col = start['line'], start['character'] + end_line, end_col = end['line'], end['character'] + + start_pos = self.get_position_line_number(start_line, start_col) + end_pos = self.get_position_line_number(end_line, end_col) + + # Replace repl_text eols for '\n' to match the ones used in + # `text`. + repl_eol = sourcecode.get_eol_chars(repl_text) + if repl_eol is not None and repl_eol != '\n': + repl_text = repl_text.replace(repl_eol, '\n') + + text_tokens = list(text_tokens) + this_edit = list(repl_text) + + if end_line == self.document().blockCount(): + end_pos = self.get_position('eof') + end_pos += 1 + + if (end_pos == len(text_tokens) and + text_tokens[end_pos - 1] == '\n'): + end_pos += 1 + + this_edition = (text_tokens[:max(start_pos - 1, 0)] + + this_edit + + text_tokens[end_pos - 1:]) + + text_edit = ''.join(this_edition) + if merged_text is None: + merged_text = text_edit + else: + merged_text = merge(text_edit, merged_text, text) + + if merged_text is not None: + # Restore eol chars after applying edits. + merged_text = merged_text.replace('\n', self.get_line_separator()) + + cursor = self.textCursor() + cursor.beginEditBlock() + cursor.movePosition(QTextCursor.Start) + cursor.movePosition(QTextCursor.End, + QTextCursor.KeepAnchor) + cursor.insertText(merged_text) + cursor.endEditBlock() + + # ------------- LSP: Code folding ranges ------------------------------- + def compute_whitespace(self, line): + tab_size = self.tab_stop_width_spaces + whitespace_regex = re.compile(r'(\s+).*') + whitespace_match = whitespace_regex.match(line) + total_whitespace = 0 + if whitespace_match is not None: + whitespace_chars = whitespace_match.group(1) + whitespace_chars = whitespace_chars.replace( + '\t', tab_size * ' ') + total_whitespace = len(whitespace_chars) + return total_whitespace + + def update_whitespace_count(self, line, column): + self.leading_whitespaces = {} + lines = to_text_string(self.toPlainText()).splitlines() + for i, text in enumerate(lines): + total_whitespace = self.compute_whitespace(text) + self.leading_whitespaces[i] = total_whitespace + + def cleanup_folding(self): + """Cleanup folding pane.""" + folding_panel = self.panels.get(FoldingPanel) + folding_panel.folding_regions = {} + + @schedule_request(method=CompletionRequestTypes.DOCUMENT_FOLDING_RANGE) + def request_folding(self): + """Request folding.""" + if not self.folding_supported or not self.code_folding: + return + params = {'file': self.filename} + return params + + @handles(CompletionRequestTypes.DOCUMENT_FOLDING_RANGE) + def handle_folding_range(self, response): + """Handle folding response.""" + ranges = response['params'] + if ranges is None: + return + + # Compute extended_ranges here because get_text_region ends up + # calling paintEvent and that method can't be called in a + # thread due to Qt restrictions. + try: + extended_ranges = [] + for start, end in ranges: + text_region = self.get_text_region(start, end) + extended_ranges.append((start, end, text_region)) + except RuntimeError: + # This is triggered when a codeeditor instance was removed + # before the response can be processed. + return + except Exception: + self.log_lsp_handle_errors("Error when processing folding") + + # Update folding in a thread + self.update_folding_thread.run = functools.partial( + self.update_and_merge_folding, extended_ranges) + self.update_folding_thread.start() + + def update_and_merge_folding(self, extended_ranges): + """Update and merge new folding information.""" + try: + folding_panel = self.panels.get(FoldingPanel) + + current_tree, root = merge_folding( + extended_ranges, folding_panel.current_tree, + folding_panel.root) + + folding_info = collect_folding_regions(root) + self._folding_info = (current_tree, root, *folding_info) + except RuntimeError: + # This is triggered when a codeeditor instance was removed + # before the response can be processed. + return + except Exception: + self.log_lsp_handle_errors("Error when processing folding") + + def finish_code_folding(self): + """Finish processing code folding.""" + folding_panel = self.panels.get(FoldingPanel) + folding_panel.update_folding(self._folding_info) + + # Update indent guides, which depend on folding + if self.indent_guides._enabled and len(self.patch) > 0: + line, column = self.get_cursor_line_column() + self.update_whitespace_count(line, column) + + # ------------- LSP: Save/close file ----------------------------------- + @schedule_request(method=CompletionRequestTypes.DOCUMENT_DID_SAVE, + requires_response=False) + def notify_save(self): + """Send save request.""" + params = {'file': self.filename} + if self.save_include_text: + params['text'] = self.get_text_with_eol() + return params + + @request(method=CompletionRequestTypes.DOCUMENT_DID_CLOSE, + requires_response=False) + def notify_close(self): + """Send close request.""" + self._pending_server_requests = [] + self._server_requests_timer.stop() + if self.completions_available: + # This is necessary to prevent an error in our tests. + try: + # Servers can send an empty publishDiagnostics reply to clear + # diagnostics after they receive a didClose request. Since + # we also ask for symbols and folding when processing + # diagnostics, we need to prevent it from happening + # before sending that request here. + self._timer_sync_symbols_and_folding.timeout.disconnect( + self.sync_symbols_and_folding) + except (TypeError, RuntimeError): + pass + + params = { + 'file': self.filename, + 'codeeditor': self + } + return params + + # ------------------------------------------------------------------------- + def set_debug_panel(self, show_debug_panel, language): + """Enable/disable debug panel.""" + debugger_panel = self.panels.get(DebuggerPanel) + if (is_text_string(language) and + language.lower() in ALL_LANGUAGES['Python'] and + show_debug_panel): + debugger_panel.setVisible(True) + else: + debugger_panel.setVisible(False) + + def update_debugger_panel_state(self, state, last_step, force=False): + """Update debugger panel state.""" + debugger_panel = self.panels.get(DebuggerPanel) + if force: + debugger_panel.start_clean() + return + elif state and 'fname' in last_step: + fname = last_step['fname'] + if (fname and self.filename + and osp.normcase(fname) == osp.normcase(self.filename)): + debugger_panel.start_clean() + return + debugger_panel.stop_clean() + + def set_folding_panel(self, folding): + """Enable/disable folding panel.""" + folding_panel = self.panels.get(FoldingPanel) + folding_panel.setVisible(folding) + + def set_tab_mode(self, enable): + """ + enabled = tab always indent + (otherwise tab indents only when cursor is at the beginning of a line) + """ + self.tab_mode = enable + + def set_strip_mode(self, enable): + """ + Strip all trailing spaces if enabled. + """ + self.strip_trailing_spaces_on_modify = enable + + def toggle_intelligent_backspace(self, state): + self.intelligent_backspace = state + + def toggle_automatic_completions(self, state): + self.automatic_completions = state + + def toggle_hover_hints(self, state): + self.hover_hints_enabled = state + + def toggle_code_snippets(self, state): + self.code_snippets = state + + def toggle_format_on_save(self, state): + self.format_on_save = state + + def toggle_code_folding(self, state): + self.code_folding = state + self.set_folding_panel(state) + if not state and self.indent_guides._enabled: + self.code_folding = True + + def toggle_identation_guides(self, state): + if state and not self.code_folding: + self.code_folding = True + self.indent_guides.set_enabled(state) + + def toggle_completions_hint(self, state): + """Enable/disable completion hint.""" + self.completions_hint = state + + def set_automatic_completions_after_chars(self, number): + """ + Set the number of characters after which auto completion is fired. + """ + self.automatic_completions_after_chars = number + + def set_automatic_completions_after_ms(self, ms): + """ + Set the amount of time in ms after which auto completion is fired. + """ + self.automatic_completions_after_ms = ms + + def set_completions_hint_after_ms(self, ms): + """ + Set the amount of time in ms after which the completions hint is shown. + """ + self.completions_hint_after_ms = ms + + def set_close_parentheses_enabled(self, enable): + """Enable/disable automatic parentheses insertion feature""" + self.close_parentheses_enabled = enable + bracket_extension = self.editor_extensions.get(CloseBracketsExtension) + if bracket_extension is not None: + bracket_extension.enabled = enable + + def set_close_quotes_enabled(self, enable): + """Enable/disable automatic quote insertion feature""" + self.close_quotes_enabled = enable + quote_extension = self.editor_extensions.get(CloseQuotesExtension) + if quote_extension is not None: + quote_extension.enabled = enable + + def set_add_colons_enabled(self, enable): + """Enable/disable automatic colons insertion feature""" + self.add_colons_enabled = enable + + def set_auto_unindent_enabled(self, enable): + """Enable/disable automatic unindent after else/elif/finally/except""" + self.auto_unindent_enabled = enable + + def set_occurrence_highlighting(self, enable): + """Enable/disable occurrence highlighting""" + self.occurrence_highlighting = enable + if not enable: + self.__clear_occurrences() + + def set_occurrence_timeout(self, timeout): + """Set occurrence highlighting timeout (ms)""" + self.occurrence_timer.setInterval(timeout) + + def set_underline_errors_enabled(self, state): + """Toggle the underlining of errors and warnings.""" + self.underline_errors_enabled = state + if not state: + self.clear_extra_selections('code_analysis_underline') + + def set_highlight_current_line(self, enable): + """Enable/disable current line highlighting""" + self.highlight_current_line_enabled = enable + if self.highlight_current_line_enabled: + self.highlight_current_line() + else: + self.unhighlight_current_line() + + def set_highlight_current_cell(self, enable): + """Enable/disable current line highlighting""" + hl_cell_enable = enable and self.supported_cell_language + self.highlight_current_cell_enabled = hl_cell_enable + if self.highlight_current_cell_enabled: + self.highlight_current_cell() + else: + self.unhighlight_current_cell() + + def set_language(self, language, filename=None): + extra_supported_languages = {'stil': 'STIL'} + self.tab_indents = language in self.TAB_ALWAYS_INDENTS + self.comment_string = '' + self.language = 'Text' + self.supported_language = False + sh_class = sh.TextSH + language = 'None' if language is None else language + if language is not None: + for (key, value) in ALL_LANGUAGES.items(): + if language.lower() in value: + self.supported_language = True + sh_class, comment_string = self.LANGUAGES[key] + if key == 'IPython': + self.language = 'Python' + else: + self.language = key + self.comment_string = comment_string + if key in CELL_LANGUAGES: + self.supported_cell_language = True + self.has_cell_separators = True + break + + if filename is not None and not self.supported_language: + sh_class = sh.guess_pygments_highlighter(filename) + self.support_language = sh_class is not sh.TextSH + if self.support_language: + # Pygments report S for the lexer name of R files + if sh_class._lexer.name == 'S': + self.language = 'R' + else: + self.language = sh_class._lexer.name + else: + _, ext = osp.splitext(filename) + ext = ext.lower() + if ext in extra_supported_languages: + self.language = extra_supported_languages[ext] + + self._set_highlighter(sh_class) + self.completion_widget.set_language(self.language) + + def _set_highlighter(self, sh_class): + self.highlighter_class = sh_class + if self.highlighter is not None: + # Removing old highlighter + # TODO: test if leaving parent/document as is eats memory + self.highlighter.setParent(None) + self.highlighter.setDocument(None) + self.highlighter = self.highlighter_class(self.document(), + self.font(), + self.color_scheme) + self._apply_highlighter_color_scheme() + + self.highlighter.editor = self + self.highlighter.sig_font_changed.connect(self.sync_font) + self._rehighlight_timer.timeout.connect( + self.highlighter.rehighlight) + + def sync_font(self): + """Highlighter changed font, update.""" + self.setFont(self.highlighter.font) + self.sig_font_changed.emit() + + def get_cell_list(self): + """Get all cells.""" + if self.highlighter is None: + return [] + + # Filter out old cells + def good(oedata): + return oedata.is_valid() and oedata.def_type == oedata.CELL + + self.highlighter._cell_list = [ + oedata for oedata in self.highlighter._cell_list if good(oedata)] + + return sorted( + {oedata.block.blockNumber(): oedata + for oedata in self.highlighter._cell_list}.items()) + + def is_json(self): + return (isinstance(self.highlighter, sh.PygmentsSH) and + self.highlighter._lexer.name == 'JSON') + + def is_python(self): + return self.highlighter_class is sh.PythonSH + + def is_ipython(self): + return self.highlighter_class is sh.IPythonSH + + def is_python_or_ipython(self): + return self.is_python() or self.is_ipython() + + def is_cython(self): + return self.highlighter_class is sh.CythonSH + + def is_enaml(self): + return self.highlighter_class is sh.EnamlSH + + def is_python_like(self): + return (self.is_python() or self.is_ipython() + or self.is_cython() or self.is_enaml()) + + def intelligent_tab(self): + """Provide intelligent behavior for Tab key press.""" + leading_text = self.get_text('sol', 'cursor') + if not leading_text.strip() or leading_text.endswith('#'): + # blank line or start of comment + self.indent_or_replace() + elif self.in_comment_or_string() and not leading_text.endswith(' '): + # in a word in a comment + self.do_completion() + elif leading_text.endswith('import ') or leading_text[-1] == '.': + # blank import or dot completion + self.do_completion() + elif (leading_text.split()[0] in ['from', 'import'] and + ';' not in leading_text): + # import line with a single statement + # (prevents lines like: `import pdb; pdb.set_trace()`) + self.do_completion() + elif leading_text[-1] in '(,' or leading_text.endswith(', '): + self.indent_or_replace() + elif leading_text.endswith(' '): + # if the line ends with a space, indent + self.indent_or_replace() + elif re.search(r"[^\d\W]\w*\Z", leading_text, re.UNICODE): + # if the line ends with a non-whitespace character + self.do_completion() + else: + self.indent_or_replace() + + def intelligent_backtab(self): + """Provide intelligent behavior for Shift+Tab key press""" + leading_text = self.get_text('sol', 'cursor') + if not leading_text.strip(): + # blank line + self.unindent() + elif self.in_comment_or_string(): + self.unindent() + elif leading_text[-1] in '(,' or leading_text.endswith(', '): + position = self.get_position('cursor') + self.show_object_info(position) + else: + # if the line ends with any other character but comma + self.unindent() + + def rehighlight(self): + """Rehighlight the whole document.""" + if self.highlighter is not None: + self.highlighter.rehighlight() + if self.highlight_current_cell_enabled: + self.highlight_current_cell() + else: + self.unhighlight_current_cell() + if self.highlight_current_line_enabled: + self.highlight_current_line() + else: + self.unhighlight_current_line() + + def trim_trailing_spaces(self): + """Remove trailing spaces""" + cursor = self.textCursor() + cursor.beginEditBlock() + cursor.movePosition(QTextCursor.Start) + while True: + cursor.movePosition(QTextCursor.EndOfBlock) + text = to_text_string(cursor.block().text()) + length = len(text)-len(text.rstrip()) + if length > 0: + cursor.movePosition(QTextCursor.Left, QTextCursor.KeepAnchor, + length) + cursor.removeSelectedText() + if cursor.atEnd(): + break + cursor.movePosition(QTextCursor.NextBlock) + cursor.endEditBlock() + + def trim_trailing_newlines(self): + """Remove extra newlines at the end of the document.""" + cursor = self.textCursor() + cursor.beginEditBlock() + cursor.movePosition(QTextCursor.End) + line = cursor.blockNumber() + this_line = self.get_text_line(line) + previous_line = self.get_text_line(line - 1) + + # Don't try to trim new lines for a file with a single line. + # Fixes spyder-ide/spyder#16401 + if self.get_line_count() > 1: + while this_line == '': + cursor.movePosition(QTextCursor.PreviousBlock, + QTextCursor.KeepAnchor) + + if self.add_newline: + if this_line == '' and previous_line != '': + cursor.movePosition(QTextCursor.NextBlock, + QTextCursor.KeepAnchor) + + line -= 1 + if line == 0: + break + + this_line = self.get_text_line(line) + previous_line = self.get_text_line(line - 1) + + if not self.add_newline: + cursor.movePosition(QTextCursor.EndOfBlock, + QTextCursor.KeepAnchor) + + cursor.removeSelectedText() + cursor.endEditBlock() + + def add_newline_to_file(self): + """Add a newline to the end of the file if it does not exist.""" + cursor = self.textCursor() + cursor.movePosition(QTextCursor.End) + line = cursor.blockNumber() + this_line = self.get_text_line(line) + if this_line != '': + cursor.beginEditBlock() + cursor.movePosition(QTextCursor.EndOfBlock) + cursor.insertText(self.get_line_separator()) + cursor.endEditBlock() + + def fix_indentation(self): + """Replace tabs by spaces.""" + text_before = to_text_string(self.toPlainText()) + text_after = sourcecode.fix_indentation(text_before, self.indent_chars) + if text_before != text_after: + # We do the following rather than using self.setPlainText + # to benefit from QTextEdit's undo/redo feature. + self.selectAll() + self.skip_rstrip = True + self.insertPlainText(text_after) + self.skip_rstrip = False + + def get_current_object(self): + """Return current object (string) """ + source_code = to_text_string(self.toPlainText()) + offset = self.get_position('cursor') + return sourcecode.get_primary_at(source_code, offset) + + def next_cursor_position(self, position=None, + mode=QTextLayout.SkipCharacters): + """ + Get next valid cursor position. + + Adapted from: + https://github.com/qt/qtbase/blob/5.15.2/src/gui/text/qtextdocument_p.cpp#L1361 + """ + cursor = self.textCursor() + if cursor.atEnd(): + return position + if position is None: + position = cursor.position() + else: + cursor.setPosition(position) + it = cursor.block() + start = it.position() + end = start + it.length() - 1 + if (position == end): + return end + 1 + return it.layout().nextCursorPosition(position - start, mode) + start + + @Slot() + def delete(self): + """Remove selected text or next character.""" + if not self.has_selected_text(): + cursor = self.textCursor() + if not cursor.atEnd(): + cursor.setPosition( + self.next_cursor_position(), QTextCursor.KeepAnchor) + self.setTextCursor(cursor) + self.remove_selected_text() + + #------Find occurrences + def __find_first(self, text): + """Find first occurrence: scan whole document""" + flags = QTextDocument.FindCaseSensitively|QTextDocument.FindWholeWords + cursor = self.textCursor() + # Scanning whole document + cursor.movePosition(QTextCursor.Start) + regexp = QRegExp(r"\b%s\b" % QRegExp.escape(text), Qt.CaseSensitive) + cursor = self.document().find(regexp, cursor, flags) + self.__find_first_pos = cursor.position() + return cursor + + def __find_next(self, text, cursor): + """Find next occurrence""" + flags = QTextDocument.FindCaseSensitively|QTextDocument.FindWholeWords + regexp = QRegExp(r"\b%s\b" % QRegExp.escape(text), Qt.CaseSensitive) + cursor = self.document().find(regexp, cursor, flags) + if cursor.position() != self.__find_first_pos: + return cursor + + def __cursor_position_changed(self): + """Cursor position has changed""" + line, column = self.get_cursor_line_column() + self.sig_cursor_position_changed.emit(line, column) + + if self.highlight_current_cell_enabled: + self.highlight_current_cell() + else: + self.unhighlight_current_cell() + if self.highlight_current_line_enabled: + self.highlight_current_line() + else: + self.unhighlight_current_line() + if self.occurrence_highlighting: + self.occurrence_timer.start() + + # Strip if needed + self.strip_trailing_spaces() + + def __clear_occurrences(self): + """Clear occurrence markers""" + self.occurrences = [] + self.clear_extra_selections('occurrences') + self.sig_flags_changed.emit() + + def get_selection(self, cursor, foreground_color=None, + background_color=None, underline_color=None, + outline_color=None, + underline_style=QTextCharFormat.SingleUnderline): + """Get selection.""" + if cursor is None: + return + + selection = TextDecoration(cursor) + if foreground_color is not None: + selection.format.setForeground(foreground_color) + if background_color is not None: + selection.format.setBackground(background_color) + if underline_color is not None: + selection.format.setProperty(QTextFormat.TextUnderlineStyle, + to_qvariant(underline_style)) + selection.format.setProperty(QTextFormat.TextUnderlineColor, + to_qvariant(underline_color)) + if outline_color is not None: + selection.set_outline(outline_color) + return selection + + def highlight_selection(self, key, cursor, foreground_color=None, + background_color=None, underline_color=None, + outline_color=None, + underline_style=QTextCharFormat.SingleUnderline): + + selection = self.get_selection( + cursor, foreground_color, background_color, underline_color, + outline_color, underline_style) + if selection is None: + return + extra_selections = self.get_extra_selections(key) + extra_selections.append(selection) + self.set_extra_selections(key, extra_selections) + + def __mark_occurrences(self): + """Marking occurrences of the currently selected word""" + self.__clear_occurrences() + + if not self.supported_language: + return + + text = self.get_selected_text().strip() + if not text: + text = self.get_current_word() + if text is None: + return + if (self.has_selected_text() and + self.get_selected_text().strip() != text): + return + + if (self.is_python_like() and + (sourcecode.is_keyword(to_text_string(text)) or + to_text_string(text) == 'self')): + return + + # Highlighting all occurrences of word *text* + cursor = self.__find_first(text) + self.occurrences = [] + extra_selections = self.get_extra_selections('occurrences') + first_occurrence = None + while cursor: + block = cursor.block() + if not block.userData(): + # Add user data to check block validity + block.setUserData(BlockUserData(self)) + self.occurrences.append(block) + + selection = self.get_selection(cursor) + if len(selection.cursor.selectedText()) > 0: + extra_selections.append(selection) + if len(extra_selections) == 1: + first_occurrence = selection + else: + selection.format.setBackground(self.occurrence_color) + first_occurrence.format.setBackground( + self.occurrence_color) + cursor = self.__find_next(text, cursor) + self.set_extra_selections('occurrences', extra_selections) + + if len(self.occurrences) > 1 and self.occurrences[-1] == 0: + # XXX: this is never happening with PySide but it's necessary + # for PyQt4... this must be related to a different behavior for + # the QTextDocument.find function between those two libraries + self.occurrences.pop(-1) + self.sig_flags_changed.emit() + + #-----highlight found results (find/replace widget) + def highlight_found_results(self, pattern, word=False, regexp=False, + case=False): + """Highlight all found patterns""" + pattern = to_text_string(pattern) + if not pattern: + return + if not regexp: + pattern = re.escape(to_text_string(pattern)) + pattern = r"\b%s\b" % pattern if word else pattern + text = to_text_string(self.toPlainText()) + re_flags = re.MULTILINE if case else re.IGNORECASE | re.MULTILINE + try: + regobj = re.compile(pattern, flags=re_flags) + except sre_constants.error: + return + extra_selections = [] + self.found_results = [] + has_unicode = len(text) != qstring_length(text) + for match in regobj.finditer(text): + if has_unicode: + pos1, pos2 = sh.get_span(match) + else: + pos1, pos2 = match.span() + selection = TextDecoration(self.textCursor()) + selection.format.setBackground(self.found_results_color) + selection.cursor.setPosition(pos1) + + block = selection.cursor.block() + if not block.userData(): + # Add user data to check block validity + block.setUserData(BlockUserData(self)) + self.found_results.append(block) + + selection.cursor.setPosition(pos2, QTextCursor.KeepAnchor) + extra_selections.append(selection) + self.set_extra_selections('find', extra_selections) + + def clear_found_results(self): + """Clear found results highlighting""" + self.found_results = [] + self.clear_extra_selections('find') + self.sig_flags_changed.emit() + + def __text_has_changed(self): + """Text has changed, eventually clear found results highlighting""" + self.last_change_position = self.textCursor().position() + if self.found_results: + self.clear_found_results() + + def get_linenumberarea_width(self): + """ + Return current line number area width. + + This method is left for backward compatibility (BaseEditMixin + define it), any changes should be in LineNumberArea class. + """ + return self.linenumberarea.get_width() + + def calculate_real_position(self, point): + """Add offset to a point, to take into account the panels.""" + point.setX(point.x() + self.panels.margin_size(Panel.Position.LEFT)) + point.setY(point.y() + self.panels.margin_size(Panel.Position.TOP)) + return point + + def calculate_real_position_from_global(self, point): + """Add offset to a point, to take into account the panels.""" + point.setX(point.x() - self.panels.margin_size(Panel.Position.LEFT)) + point.setY(point.y() + self.panels.margin_size(Panel.Position.TOP)) + return point + + def get_linenumber_from_mouse_event(self, event): + """Return line number from mouse event""" + block = self.firstVisibleBlock() + line_number = block.blockNumber() + top = self.blockBoundingGeometry(block).translated( + self.contentOffset()).top() + bottom = top + self.blockBoundingRect(block).height() + while block.isValid() and top < event.pos().y(): + block = block.next() + if block.isVisible(): # skip collapsed blocks + top = bottom + bottom = top + self.blockBoundingRect(block).height() + line_number += 1 + return line_number + + def select_lines(self, linenumber_pressed, linenumber_released): + """Select line(s) after a mouse press/mouse press drag event""" + find_block_by_number = self.document().findBlockByNumber + move_n_blocks = (linenumber_released - linenumber_pressed) + start_line = linenumber_pressed + start_block = find_block_by_number(start_line - 1) + + cursor = self.textCursor() + cursor.setPosition(start_block.position()) + + # Select/drag downwards + if move_n_blocks > 0: + for n in range(abs(move_n_blocks) + 1): + cursor.movePosition(cursor.NextBlock, cursor.KeepAnchor) + # Select/drag upwards or select single line + else: + cursor.movePosition(cursor.NextBlock) + for n in range(abs(move_n_blocks) + 1): + cursor.movePosition(cursor.PreviousBlock, cursor.KeepAnchor) + + # Account for last line case + if linenumber_released == self.blockCount(): + cursor.movePosition(cursor.EndOfBlock, cursor.KeepAnchor) + else: + cursor.movePosition(cursor.StartOfBlock, cursor.KeepAnchor) + + self.setTextCursor(cursor) + + # ----- Code bookmarks + def add_bookmark(self, slot_num, line=None, column=None): + """Add bookmark to current block's userData.""" + if line is None: + # Triggered by shortcut, else by spyder start + line, column = self.get_cursor_line_column() + block = self.document().findBlockByNumber(line) + data = block.userData() + if not data: + data = BlockUserData(self) + if slot_num not in data.bookmarks: + data.bookmarks.append((slot_num, column)) + block.setUserData(data) + self._bookmarks_blocks[id(block)] = block + self.sig_bookmarks_changed.emit() + + def get_bookmarks(self): + """Get bookmarks by going over all blocks.""" + bookmarks = {} + pruned_bookmarks_blocks = {} + for block_id in self._bookmarks_blocks: + block = self._bookmarks_blocks[block_id] + if block.isValid(): + data = block.userData() + if data and data.bookmarks: + pruned_bookmarks_blocks[block_id] = block + line_number = block.blockNumber() + for slot_num, column in data.bookmarks: + bookmarks[slot_num] = [line_number, column] + self._bookmarks_blocks = pruned_bookmarks_blocks + return bookmarks + + def clear_bookmarks(self): + """Clear bookmarks for all blocks.""" + self.bookmarks = {} + for data in self.blockuserdata_list(): + data.bookmarks = [] + self._bookmarks_blocks = {} + + def set_bookmarks(self, bookmarks): + """Set bookmarks when opening file.""" + self.clear_bookmarks() + for slot_num, bookmark in bookmarks.items(): + self.add_bookmark(slot_num, bookmark[1], bookmark[2]) + + def update_bookmarks(self): + """Emit signal to update bookmarks.""" + self.sig_bookmarks_changed.emit() + + # -----Code introspection + def show_completion_object_info(self, name, signature): + """Trigger show completion info in Help Pane.""" + force = True + self.sig_show_completion_object_info.emit(name, signature, force) + + def show_object_info(self, position): + """Trigger a calltip""" + self.sig_show_object_info.emit(position) + + # -----blank spaces + def set_blanks_enabled(self, state): + """Toggle blanks visibility""" + self.blanks_enabled = state + option = self.document().defaultTextOption() + option.setFlags(option.flags() | \ + QTextOption.AddSpaceForLineAndParagraphSeparators) + if self.blanks_enabled: + option.setFlags(option.flags() | QTextOption.ShowTabsAndSpaces) + else: + option.setFlags(option.flags() & ~QTextOption.ShowTabsAndSpaces) + self.document().setDefaultTextOption(option) + # Rehighlight to make the spaces less apparent. + self.rehighlight() + + def set_scrollpastend_enabled(self, state): + """ + Allow user to scroll past the end of the document to have the last + line on top of the screen + """ + self.scrollpastend_enabled = state + self.setCenterOnScroll(state) + self.setDocument(self.document()) + + def resizeEvent(self, event): + """Reimplemented Qt method to handle p resizing""" + TextEditBaseWidget.resizeEvent(self, event) + self.panels.resize() + + def showEvent(self, event): + """Overrides showEvent to update the viewport margins.""" + super(CodeEditor, self).showEvent(event) + self.panels.refresh() + + #-----Misc. + def _apply_highlighter_color_scheme(self): + """Apply color scheme from syntax highlighter to the editor""" + hl = self.highlighter + if hl is not None: + self.set_palette(background=hl.get_background_color(), + foreground=hl.get_foreground_color()) + self.currentline_color = hl.get_currentline_color() + self.currentcell_color = hl.get_currentcell_color() + self.occurrence_color = hl.get_occurrence_color() + self.ctrl_click_color = hl.get_ctrlclick_color() + self.sideareas_color = hl.get_sideareas_color() + self.comment_color = hl.get_comment_color() + self.normal_color = hl.get_foreground_color() + self.matched_p_color = hl.get_matched_p_color() + self.unmatched_p_color = hl.get_unmatched_p_color() + + self.edge_line.update_color() + self.indent_guides.update_color() + + self.sig_theme_colors_changed.emit( + {'occurrence': self.occurrence_color}) + + def apply_highlighter_settings(self, color_scheme=None): + """Apply syntax highlighter settings""" + if self.highlighter is not None: + # Updating highlighter settings (font and color scheme) + self.highlighter.setup_formats(self.font()) + if color_scheme is not None: + self.set_color_scheme(color_scheme) + else: + self._rehighlight_timer.start() + + def set_font(self, font, color_scheme=None): + """Set font""" + # Note: why using this method to set color scheme instead of + # 'set_color_scheme'? To avoid rehighlighting the document twice + # at startup. + if color_scheme is not None: + self.color_scheme = color_scheme + self.setFont(font) + self.panels.refresh() + self.apply_highlighter_settings(color_scheme) + + def set_color_scheme(self, color_scheme): + """Set color scheme for syntax highlighting""" + self.color_scheme = color_scheme + if self.highlighter is not None: + # this calls self.highlighter.rehighlight() + self.highlighter.set_color_scheme(color_scheme) + self._apply_highlighter_color_scheme() + if self.highlight_current_cell_enabled: + self.highlight_current_cell() + else: + self.unhighlight_current_cell() + if self.highlight_current_line_enabled: + self.highlight_current_line() + else: + self.unhighlight_current_line() + + def set_text(self, text): + """Set the text of the editor""" + self.setPlainText(text) + self.set_eol_chars(text=text) + + if (isinstance(self.highlighter, sh.PygmentsSH) + and not running_under_pytest()): + self.highlighter.make_charlist() + + def set_text_from_file(self, filename, language=None): + """Set the text of the editor from file *fname*""" + self.filename = filename + text, _enc = encoding.read(filename) + if language is None: + language = get_file_language(filename, text) + self.set_language(language, filename) + self.set_text(text) + + def append(self, text): + """Append text to the end of the text widget""" + cursor = self.textCursor() + cursor.movePosition(QTextCursor.End) + cursor.insertText(text) + + def adjust_indentation(self, line, indent_adjustment): + """Adjust indentation.""" + if indent_adjustment == 0 or line == "": + return line + using_spaces = self.indent_chars != '\t' + + if indent_adjustment > 0: + if using_spaces: + return ' ' * indent_adjustment + line + else: + return ( + self.indent_chars + * (indent_adjustment // self.tab_stop_width_spaces) + + line) + + max_indent = self.get_line_indentation(line) + indent_adjustment = min(max_indent, -indent_adjustment) + + indent_adjustment = (indent_adjustment if using_spaces else + indent_adjustment // self.tab_stop_width_spaces) + + return line[indent_adjustment:] + + @Slot() + def paste(self): + """ + Insert text or file/folder path copied from clipboard. + + Reimplement QPlainTextEdit's method to fix the following issue: + on Windows, pasted text has only 'LF' EOL chars even if the original + text has 'CRLF' EOL chars. + The function also changes the clipboard data if they are copied as + files/folders but does not change normal text data except if they are + multiple lines. Since we are changing clipboard data we cannot use + paste, which directly pastes from clipboard instead we use + insertPlainText and pass the formatted/changed text without modifying + clipboard content. + """ + clipboard = QApplication.clipboard() + text = to_text_string(clipboard.text()) + + if clipboard.mimeData().hasUrls(): + # Have copied file and folder urls pasted as text paths. + # See spyder-ide/spyder#8644 for details. + urls = clipboard.mimeData().urls() + if all([url.isLocalFile() for url in urls]): + if len(urls) > 1: + sep_chars = ',' + self.get_line_separator() + text = sep_chars.join('"' + url.toLocalFile(). + replace(osp.os.sep, '/') + + '"' for url in urls) + else: + # The `urls` list can be empty, so we need to check that + # before proceeding. + # Fixes spyder-ide/spyder#17521 + if urls: + text = urls[0].toLocalFile().replace(osp.os.sep, '/') + + eol_chars = self.get_line_separator() + if len(text.splitlines()) > 1: + text = eol_chars.join((text + eol_chars).splitlines()) + + # Align multiline text based on first line + cursor = self.textCursor() + cursor.beginEditBlock() + cursor.removeSelectedText() + cursor.setPosition(cursor.selectionStart()) + cursor.setPosition(cursor.block().position(), + QTextCursor.KeepAnchor) + preceding_text = cursor.selectedText() + first_line_selected, *remaining_lines = (text + eol_chars).splitlines() + first_line = preceding_text + first_line_selected + + first_line_adjustment = 0 + + # Dedent if automatic indentation makes code invalid + # Minimum indentation = max of current and paster indentation + if (self.is_python_like() and len(preceding_text.strip()) == 0 + and len(first_line.strip()) > 0): + # Correct indentation + desired_indent = self.find_indentation() + if desired_indent: + # minimum indentation is either the current indentation + # or the indentation of the paster text + desired_indent = max( + desired_indent, + self.get_line_indentation(first_line_selected), + self.get_line_indentation(preceding_text)) + first_line_adjustment = ( + desired_indent - self.get_line_indentation(first_line)) + # Only dedent, don't indent + first_line_adjustment = min(first_line_adjustment, 0) + # Only dedent, don't indent + first_line = self.adjust_indentation( + first_line, first_line_adjustment) + + # Fix indentation of multiline text based on first line + if len(remaining_lines) > 0 and len(first_line.strip()) > 0: + lines_adjustment = first_line_adjustment + lines_adjustment += CLIPBOARD_HELPER.remaining_lines_adjustment( + preceding_text) + + # Make sure the code is not flattened + indentations = [ + self.get_line_indentation(line) + for line in remaining_lines if line.strip() != ""] + if indentations: + max_dedent = min(indentations) + lines_adjustment = max(lines_adjustment, -max_dedent) + # Get new text + remaining_lines = [ + self.adjust_indentation(line, lines_adjustment) + for line in remaining_lines] + + text = eol_chars.join([first_line, *remaining_lines]) + + self.skip_rstrip = True + self.sig_will_paste_text.emit(text) + cursor.removeSelectedText() + cursor.insertText(text) + cursor.endEditBlock() + self.sig_text_was_inserted.emit() + + self.skip_rstrip = False + + def _save_clipboard_indentation(self): + """ + Save the indentation corresponding to the clipboard data. + + Must be called right after copying. + """ + cursor = self.textCursor() + cursor.setPosition(cursor.selectionStart()) + cursor.setPosition(cursor.block().position(), + QTextCursor.KeepAnchor) + preceding_text = cursor.selectedText() + CLIPBOARD_HELPER.save_indentation( + preceding_text, self.tab_stop_width_spaces) + + @Slot() + def cut(self): + """Reimplement cut to signal listeners about changes on the text.""" + has_selected_text = self.has_selected_text() + if not has_selected_text: + return + start, end = self.get_selection_start_end() + self.sig_will_remove_selection.emit(start, end) + TextEditBaseWidget.cut(self) + self._save_clipboard_indentation() + self.sig_text_was_inserted.emit() + + @Slot() + def copy(self): + """Reimplement copy to save indentation.""" + TextEditBaseWidget.copy(self) + self._save_clipboard_indentation() + + @Slot() + def undo(self): + """Reimplement undo to decrease text version number.""" + if self.document().isUndoAvailable(): + self.text_version -= 1 + self.skip_rstrip = True + self.is_undoing = True + TextEditBaseWidget.undo(self) + self.sig_undo.emit() + self.sig_text_was_inserted.emit() + self.is_undoing = False + self.skip_rstrip = False + + @Slot() + def redo(self): + """Reimplement redo to increase text version number.""" + if self.document().isRedoAvailable(): + self.text_version += 1 + self.skip_rstrip = True + self.is_redoing = True + TextEditBaseWidget.redo(self) + self.sig_redo.emit() + self.sig_text_was_inserted.emit() + self.is_redoing = False + self.skip_rstrip = False + + # ========================================================================= + # High-level editor features + # ========================================================================= + @Slot() + def center_cursor_on_next_focus(self): + """QPlainTextEdit's "centerCursor" requires the widget to be visible""" + self.centerCursor() + self.focus_in.disconnect(self.center_cursor_on_next_focus) + + def go_to_line(self, line, start_column=0, end_column=0, word=''): + """Go to line number *line* and eventually highlight it""" + self.text_helper.goto_line(line, column=start_column, + end_column=end_column, move=True, + word=word) + + def exec_gotolinedialog(self): + """Execute the GoToLineDialog dialog box""" + dlg = GoToLineDialog(self) + if dlg.exec_(): + self.go_to_line(dlg.get_line_number()) + + def hide_tooltip(self): + """ + Hide the tooltip widget. + + The tooltip widget is a special QLabel that looks like a tooltip, + this method is here so it can be hidden as necessary. For example, + when the user leaves the Linenumber area when hovering over lint + warnings and errors. + """ + self._timer_mouse_moving.stop() + self._last_hover_word = None + self.clear_extra_selections('code_analysis_highlight') + if self.tooltip_widget.isVisible(): + self.tooltip_widget.hide() + + def _set_completions_hint_idle(self): + self._completions_hint_idle = True + self.completion_widget.trigger_completion_hint() + + # --- Hint for completions + def show_hint_for_completion(self, word, documentation, at_point): + """Show hint for completion element.""" + if self.completions_hint and self._completions_hint_idle: + documentation = documentation.replace(u'\xa0', ' ') + completion_doc = {'name': word, + 'signature': documentation} + + if documentation and len(documentation) > 0: + self.show_hint( + documentation, + inspect_word=word, + at_point=at_point, + completion_doc=completion_doc, + max_lines=self._DEFAULT_MAX_LINES, + max_width=self._DEFAULT_COMPLETION_HINT_MAX_WIDTH) + self.tooltip_widget.move(at_point) + return + self.hide_tooltip() + + def update_decorations(self): + """Update decorations on the visible portion of the screen.""" + if self.underline_errors_enabled: + self.underline_errors() + + # This is required to update decorations whether there are or not + # underline errors in the visible portion of the screen. + # See spyder-ide/spyder#14268. + self.decorations.update() + + def show_code_analysis_results(self, line_number, block_data): + """Show warning/error messages.""" + # Diagnostic severity + icons = { + DiagnosticSeverity.ERROR: 'error', + DiagnosticSeverity.WARNING: 'warning', + DiagnosticSeverity.INFORMATION: 'information', + DiagnosticSeverity.HINT: 'hint', + } + + code_analysis = block_data.code_analysis + + # Size must be adapted from font + fm = self.fontMetrics() + size = fm.height() + template = ( + ' ' + '{} ({} {})' + ) + + msglist = [] + max_lines_msglist = 25 + sorted_code_analysis = sorted(code_analysis, key=lambda i: i[2]) + for src, code, sev, msg in sorted_code_analysis: + if src == 'pylint' and '[' in msg and ']' in msg: + # Remove extra redundant info from pylint messages + msg = msg.split(']')[-1] + + msg = msg.strip() + # Avoid messing TODO, FIXME + # Prevent error if msg only has one element + if len(msg) > 1: + msg = msg[0].upper() + msg[1:] + + # Get individual lines following paragraph format and handle + # symbols like '<' and '>' to not mess with br tags + msg = msg.replace('<', '<').replace('>', '>') + paragraphs = msg.splitlines() + new_paragraphs = [] + long_paragraphs = 0 + lines_per_message = 6 + for paragraph in paragraphs: + new_paragraph = textwrap.wrap( + paragraph, + width=self._DEFAULT_MAX_HINT_WIDTH) + if lines_per_message > 2: + if len(new_paragraph) > 1: + new_paragraph = '
'.join(new_paragraph[:2]) + '...' + long_paragraphs += 1 + lines_per_message -= 2 + else: + new_paragraph = '
'.join(new_paragraph) + lines_per_message -= 1 + new_paragraphs.append(new_paragraph) + + if len(new_paragraphs) > 1: + # Define max lines taking into account that in the same + # tooltip you can find multiple warnings and messages + # and each one can have multiple lines + if long_paragraphs != 0: + max_lines = 3 + max_lines_msglist -= max_lines * 2 + else: + max_lines = 5 + max_lines_msglist -= max_lines + msg = '
'.join(new_paragraphs[:max_lines]) + '
' + else: + msg = '
'.join(new_paragraphs) + + base_64 = ima.base64_from_icon(icons[sev], size, size) + if max_lines_msglist >= 0: + msglist.append(template.format(base_64, msg, src, + code, size=size)) + + if msglist: + self.show_tooltip( + title=_("Code analysis"), + text='\n'.join(msglist), + title_color=QStylePalette.COLOR_ACCENT_4, + at_line=line_number, + with_html_format=True + ) + self.highlight_line_warning(block_data) + + def highlight_line_warning(self, block_data): + """Highlight errors and warnings in this editor.""" + self.clear_extra_selections('code_analysis_highlight') + self.highlight_selection('code_analysis_highlight', + block_data._selection(), + background_color=block_data.color) + self.linenumberarea.update() + + def get_current_warnings(self): + """ + Get all warnings for the current editor and return + a list with the message and line number. + """ + block = self.document().firstBlock() + line_count = self.document().blockCount() + warnings = [] + while True: + data = block.userData() + if data and data.code_analysis: + for warning in data.code_analysis: + warnings.append([warning[-1], block.blockNumber() + 1]) + # See spyder-ide/spyder#9924 + if block.blockNumber() + 1 == line_count: + break + block = block.next() + return warnings + + def go_to_next_warning(self): + """ + Go to next code warning message and return new cursor position. + """ + block = self.textCursor().block() + line_count = self.document().blockCount() + for __ in range(line_count): + line_number = block.blockNumber() + 1 + if line_number < line_count: + block = block.next() + else: + block = self.document().firstBlock() + + data = block.userData() + if data and data.code_analysis: + line_number = block.blockNumber() + 1 + self.go_to_line(line_number) + self.show_code_analysis_results(line_number, data) + return self.get_position('cursor') + + def go_to_previous_warning(self): + """ + Go to previous code warning message and return new cursor position. + """ + block = self.textCursor().block() + line_count = self.document().blockCount() + for __ in range(line_count): + line_number = block.blockNumber() + 1 + if line_number > 1: + block = block.previous() + else: + block = self.document().lastBlock() + + data = block.userData() + if data and data.code_analysis: + line_number = block.blockNumber() + 1 + self.go_to_line(line_number) + self.show_code_analysis_results(line_number, data) + return self.get_position('cursor') + + def cell_list(self): + """Get the outline explorer data for all cells.""" + for oedata in self.outlineexplorer_data_list(): + if oedata.def_type == OED.CELL: + yield oedata + + def get_cell_code(self, cell): + """ + Get cell code for a given cell. + + If the cell doesn't exist, raises an exception + """ + selected_block = None + if is_string(cell): + for oedata in self.cell_list(): + if oedata.def_name == cell: + selected_block = oedata.block + break + else: + if cell == 0: + selected_block = self.document().firstBlock() + else: + cell_list = list(self.cell_list()) + if cell <= len(cell_list): + selected_block = cell_list[cell - 1].block + + if not selected_block: + raise RuntimeError("Cell {} not found.".format(repr(cell))) + + cursor = QTextCursor(selected_block) + cell_code, _ = self.get_cell_as_executable_code(cursor) + return cell_code + + def get_cell_count(self): + """Get number of cells in document.""" + return 1 + len(list(self.cell_list())) + + #------Tasks management + def go_to_next_todo(self): + """Go to next todo and return new cursor position""" + block = self.textCursor().block() + line_count = self.document().blockCount() + while True: + if block.blockNumber()+1 < line_count: + block = block.next() + else: + block = self.document().firstBlock() + data = block.userData() + if data and data.todo: + break + line_number = block.blockNumber()+1 + self.go_to_line(line_number) + self.show_tooltip( + title=_("To do"), + text=data.todo, + title_color=QStylePalette.COLOR_ACCENT_4, + at_line=line_number, + ) + + return self.get_position('cursor') + + def process_todo(self, todo_results): + """Process todo finder results""" + for data in self.blockuserdata_list(): + data.todo = '' + + for message, line_number in todo_results: + block = self.document().findBlockByNumber(line_number - 1) + data = block.userData() + if not data: + data = BlockUserData(self) + data.todo = message + block.setUserData(data) + self.sig_flags_changed.emit() + + #------Comments/Indentation + def add_prefix(self, prefix): + """Add prefix to current line or selected line(s)""" + cursor = self.textCursor() + if self.has_selected_text(): + # Add prefix to selected line(s) + start_pos, end_pos = cursor.selectionStart(), cursor.selectionEnd() + + # Let's see if selection begins at a block start + first_pos = min([start_pos, end_pos]) + first_cursor = self.textCursor() + first_cursor.setPosition(first_pos) + + cursor.beginEditBlock() + cursor.setPosition(end_pos) + # Check if end_pos is at the start of a block: if so, starting + # changes from the previous block + if cursor.atBlockStart(): + cursor.movePosition(QTextCursor.PreviousBlock) + if cursor.position() < start_pos: + cursor.setPosition(start_pos) + move_number = self.__spaces_for_prefix() + + while cursor.position() >= start_pos: + cursor.movePosition(QTextCursor.StartOfBlock) + line_text = to_text_string(cursor.block().text()) + if (self.get_character(cursor.position()) == ' ' + and '#' in prefix and not line_text.isspace() + or (not line_text.startswith(' ') + and line_text != '')): + cursor.movePosition(QTextCursor.Right, + QTextCursor.MoveAnchor, + move_number) + cursor.insertText(prefix) + elif '#' not in prefix: + cursor.insertText(prefix) + if cursor.blockNumber() == 0: + # Avoid infinite loop when indenting the very first line + break + cursor.movePosition(QTextCursor.PreviousBlock) + cursor.movePosition(QTextCursor.EndOfBlock) + cursor.endEditBlock() + else: + # Add prefix to current line + cursor.beginEditBlock() + cursor.movePosition(QTextCursor.StartOfBlock) + if self.get_character(cursor.position()) == ' ' and '#' in prefix: + cursor.movePosition(QTextCursor.NextWord) + cursor.insertText(prefix) + cursor.endEditBlock() + + def __spaces_for_prefix(self): + """Find the less indented level of text.""" + cursor = self.textCursor() + if self.has_selected_text(): + # Add prefix to selected line(s) + start_pos, end_pos = cursor.selectionStart(), cursor.selectionEnd() + + # Let's see if selection begins at a block start + first_pos = min([start_pos, end_pos]) + first_cursor = self.textCursor() + first_cursor.setPosition(first_pos) + + cursor.beginEditBlock() + cursor.setPosition(end_pos) + # Check if end_pos is at the start of a block: if so, starting + # changes from the previous block + if cursor.atBlockStart(): + cursor.movePosition(QTextCursor.PreviousBlock) + if cursor.position() < start_pos: + cursor.setPosition(start_pos) + + number_spaces = -1 + while cursor.position() >= start_pos: + cursor.movePosition(QTextCursor.StartOfBlock) + line_text = to_text_string(cursor.block().text()) + start_with_space = line_text.startswith(' ') + left_number_spaces = self.__number_of_spaces(line_text) + if not start_with_space: + left_number_spaces = 0 + if ((number_spaces == -1 + or number_spaces > left_number_spaces) + and not line_text.isspace() and line_text != ''): + number_spaces = left_number_spaces + if cursor.blockNumber() == 0: + # Avoid infinite loop when indenting the very first line + break + cursor.movePosition(QTextCursor.PreviousBlock) + cursor.movePosition(QTextCursor.EndOfBlock) + cursor.endEditBlock() + return number_spaces + + def remove_suffix(self, suffix): + """ + Remove suffix from current line (there should not be any selection) + """ + cursor = self.textCursor() + cursor.setPosition(cursor.position() - qstring_length(suffix), + QTextCursor.KeepAnchor) + if to_text_string(cursor.selectedText()) == suffix: + cursor.removeSelectedText() + + def remove_prefix(self, prefix): + """Remove prefix from current line or selected line(s)""" + cursor = self.textCursor() + if self.has_selected_text(): + # Remove prefix from selected line(s) + start_pos, end_pos = sorted([cursor.selectionStart(), + cursor.selectionEnd()]) + cursor.setPosition(start_pos) + if not cursor.atBlockStart(): + cursor.movePosition(QTextCursor.StartOfBlock) + start_pos = cursor.position() + cursor.beginEditBlock() + cursor.setPosition(end_pos) + # Check if end_pos is at the start of a block: if so, starting + # changes from the previous block + if cursor.atBlockStart(): + cursor.movePosition(QTextCursor.PreviousBlock) + if cursor.position() < start_pos: + cursor.setPosition(start_pos) + + cursor.movePosition(QTextCursor.StartOfBlock) + old_pos = None + while cursor.position() >= start_pos: + new_pos = cursor.position() + if old_pos == new_pos: + break + else: + old_pos = new_pos + line_text = to_text_string(cursor.block().text()) + self.__remove_prefix(prefix, cursor, line_text) + cursor.movePosition(QTextCursor.PreviousBlock) + cursor.endEditBlock() + else: + # Remove prefix from current line + cursor.movePosition(QTextCursor.StartOfBlock) + line_text = to_text_string(cursor.block().text()) + self.__remove_prefix(prefix, cursor, line_text) + + def __remove_prefix(self, prefix, cursor, line_text): + """Handle the removal of the prefix for a single line.""" + start_with_space = line_text.startswith(' ') + if start_with_space: + left_spaces = self.__even_number_of_spaces(line_text) + else: + left_spaces = False + if start_with_space: + right_number_spaces = self.__number_of_spaces(line_text, group=1) + else: + right_number_spaces = self.__number_of_spaces(line_text) + # Handle prefix remove for comments with spaces + if (prefix.strip() and line_text.lstrip().startswith(prefix + ' ') + or line_text.startswith(prefix + ' ') and '#' in prefix): + cursor.movePosition(QTextCursor.Right, + QTextCursor.MoveAnchor, + line_text.find(prefix)) + if (right_number_spaces == 1 + and (left_spaces or not start_with_space) + or (not start_with_space and right_number_spaces % 2 != 0) + or (left_spaces and right_number_spaces % 2 != 0)): + # Handle inserted '# ' with the count of the number of spaces + # at the right and left of the prefix. + cursor.movePosition(QTextCursor.Right, + QTextCursor.KeepAnchor, len(prefix + ' ')) + else: + # Handle manual insertion of '#' + cursor.movePosition(QTextCursor.Right, + QTextCursor.KeepAnchor, len(prefix)) + cursor.removeSelectedText() + # Check for prefix without space + elif (prefix.strip() and line_text.lstrip().startswith(prefix) + or line_text.startswith(prefix)): + cursor.movePosition(QTextCursor.Right, + QTextCursor.MoveAnchor, + line_text.find(prefix)) + cursor.movePosition(QTextCursor.Right, + QTextCursor.KeepAnchor, len(prefix)) + cursor.removeSelectedText() + + def __even_number_of_spaces(self, line_text, group=0): + """ + Get if there is a correct indentation from a group of spaces of a line. + """ + spaces = re.findall(r'\s+', line_text) + if len(spaces) - 1 >= group: + return len(spaces[group]) % len(self.indent_chars) == 0 + + def __number_of_spaces(self, line_text, group=0): + """Get the number of spaces from a group of spaces in a line.""" + spaces = re.findall(r'\s+', line_text) + if len(spaces) - 1 >= group: + return len(spaces[group]) + + def __get_brackets(self, line_text, closing_brackets=[]): + """ + Return unmatched opening brackets and left-over closing brackets. + + (str, []) -> ([(pos, bracket)], [bracket], comment_pos) + + Iterate through line_text to find unmatched brackets. + + Returns three objects as a tuple: + 1) bracket_stack: + a list of tuples of pos and char of each unmatched opening bracket + 2) closing brackets: + this line's unmatched closing brackets + arg closing_brackets. + If this line ad no closing brackets, arg closing_brackets might + be matched with previously unmatched opening brackets in this line. + 3) Pos at which a # comment begins. -1 if it doesn't.' + """ + # Remove inline comment and check brackets + bracket_stack = [] # list containing this lines unmatched opening + # same deal, for closing though. Ignore if bracket stack not empty, + # since they are mismatched in that case. + bracket_unmatched_closing = [] + comment_pos = -1 + deactivate = None + escaped = False + pos, c = None, None + for pos, c in enumerate(line_text): + # Handle '\' inside strings + if escaped: + escaped = False + # Handle strings + elif deactivate: + if c == deactivate: + deactivate = None + elif c == "\\": + escaped = True + elif c in ["'", '"']: + deactivate = c + # Handle comments + elif c == "#": + comment_pos = pos + break + # Handle brackets + elif c in ('(', '[', '{'): + bracket_stack.append((pos, c)) + elif c in (')', ']', '}'): + if bracket_stack and bracket_stack[-1][1] == \ + {')': '(', ']': '[', '}': '{'}[c]: + bracket_stack.pop() + else: + bracket_unmatched_closing.append(c) + del pos, deactivate, escaped + # If no closing brackets are left over from this line, + # check the ones from previous iterations' prevlines + if not bracket_unmatched_closing: + for c in list(closing_brackets): + if bracket_stack and bracket_stack[-1][1] == \ + {')': '(', ']': '[', '}': '{'}[c]: + bracket_stack.pop() + closing_brackets.remove(c) + else: + break + del c + closing_brackets = bracket_unmatched_closing + closing_brackets + return (bracket_stack, closing_brackets, comment_pos) + + def fix_indent(self, *args, **kwargs): + """Indent line according to the preferences""" + if self.is_python_like(): + ret = self.fix_indent_smart(*args, **kwargs) + else: + ret = self.simple_indentation(*args, **kwargs) + return ret + + def simple_indentation(self, forward=True, **kwargs): + """ + Simply preserve the indentation-level of the previous line. + """ + cursor = self.textCursor() + block_nb = cursor.blockNumber() + prev_block = self.document().findBlockByNumber(block_nb - 1) + prevline = to_text_string(prev_block.text()) + + indentation = re.match(r"\s*", prevline).group() + # Unident + if not forward: + indentation = indentation[len(self.indent_chars):] + + cursor.insertText(indentation) + return False # simple indentation don't fix indentation + + def find_indentation(self, forward=True, comment_or_string=False, + cur_indent=None): + """ + Find indentation (Python only, no text selection) + + forward=True: fix indent only if text is not enough indented + (otherwise force indent) + forward=False: fix indent only if text is too much indented + (otherwise force unindent) + + comment_or_string: Do not adjust indent level for + unmatched opening brackets and keywords + + max_blank_lines: maximum number of blank lines to search before giving + up + + cur_indent: current indent. This is the indent before we started + processing. E.g. when returning, indent before rstrip. + + Returns the indentation for the current line + + Assumes self.is_python_like() to return True + """ + cursor = self.textCursor() + block_nb = cursor.blockNumber() + # find the line that contains our scope + line_in_block = False + visual_indent = False + add_indent = 0 # How many levels of indent to add + prevline = None + prevtext = "" + empty_lines = True + + closing_brackets = [] + for prevline in range(block_nb - 1, -1, -1): + cursor.movePosition(QTextCursor.PreviousBlock) + prevtext = to_text_string(cursor.block().text()).rstrip() + + bracket_stack, closing_brackets, comment_pos = self.__get_brackets( + prevtext, closing_brackets) + + if not prevtext: + continue + + if prevtext.endswith((':', '\\')): + # Presume a block was started + line_in_block = True # add one level of indent to correct_indent + # Does this variable actually do *anything* of relevance? + # comment_or_string = True + + if bracket_stack or not closing_brackets: + break + + if prevtext.strip() != '': + empty_lines = False + + if empty_lines and prevline is not None and prevline < block_nb - 2: + # The previous line is too far, ignore + prevtext = '' + prevline = block_nb - 2 + line_in_block = False + + # splits of prevtext happen a few times. Let's just do it once + words = re.split(r'[\s\(\[\{\}\]\)]', prevtext.lstrip()) + + if line_in_block: + add_indent += 1 + + if prevtext and not comment_or_string: + if bracket_stack: + # Hanging indent + if prevtext.endswith(('(', '[', '{')): + add_indent += 1 + if words[0] in ('class', 'def', 'elif', 'except', 'for', + 'if', 'while', 'with'): + add_indent += 1 + elif not ( # I'm not sure this block should exist here + ( + self.tab_stop_width_spaces + if self.indent_chars == '\t' else + len(self.indent_chars) + ) * 2 < len(prevtext)): + visual_indent = True + else: + # There's stuff after unmatched opening brackets + visual_indent = True + elif (words[-1] in ('continue', 'break', 'pass',) + or words[0] == "return" and not line_in_block + ): + add_indent -= 1 + + if prevline: + prevline_indent = self.get_block_indentation(prevline) + else: + prevline_indent = 0 + + if visual_indent: # can only be true if bracket_stack + correct_indent = bracket_stack[-1][0] + 1 + elif add_indent: + # Indent + if self.indent_chars == '\t': + correct_indent = prevline_indent + self.tab_stop_width_spaces * add_indent + else: + correct_indent = prevline_indent + len(self.indent_chars) * add_indent + else: + correct_indent = prevline_indent + + # TODO untangle this block + if prevline and not bracket_stack and not prevtext.endswith(':'): + if forward: + # Keep indentation of previous line + ref_line = block_nb - 1 + else: + # Find indentation context + ref_line = prevline + if cur_indent is None: + cur_indent = self.get_block_indentation(ref_line) + is_blank = not self.get_text_line(ref_line).strip() + trailing_text = self.get_text_line(block_nb).strip() + # If brackets are matched and no block gets opened + # Match the above line's indent and nudge to the next multiple of 4 + + if cur_indent < prevline_indent and (trailing_text or is_blank): + # if line directly above is blank or there is text after cursor + # Ceiling division + correct_indent = -(-cur_indent // len(self.indent_chars)) * \ + len(self.indent_chars) + return correct_indent + + def fix_indent_smart(self, forward=True, comment_or_string=False, + cur_indent=None): + """ + Fix indentation (Python only, no text selection) + + forward=True: fix indent only if text is not enough indented + (otherwise force indent) + forward=False: fix indent only if text is too much indented + (otherwise force unindent) + + comment_or_string: Do not adjust indent level for + unmatched opening brackets and keywords + + max_blank_lines: maximum number of blank lines to search before giving + up + + cur_indent: current indent. This is the indent before we started + processing. E.g. when returning, indent before rstrip. + + Returns True if indent needed to be fixed + + Assumes self.is_python_like() to return True + """ + cursor = self.textCursor() + block_nb = cursor.blockNumber() + indent = self.get_block_indentation(block_nb) + + correct_indent = self.find_indentation( + forward, comment_or_string, cur_indent) + + if correct_indent >= 0 and not ( + indent == correct_indent or + forward and indent > correct_indent or + not forward and indent < correct_indent + ): + # Insert the determined indent + cursor = self.textCursor() + cursor.movePosition(QTextCursor.StartOfBlock) + if self.indent_chars == '\t': + indent = indent // self.tab_stop_width_spaces + cursor.setPosition(cursor.position()+indent, QTextCursor.KeepAnchor) + cursor.removeSelectedText() + if self.indent_chars == '\t': + indent_text = ( + '\t' * (correct_indent // self.tab_stop_width_spaces) + + ' ' * (correct_indent % self.tab_stop_width_spaces) + ) + else: + indent_text = ' '*correct_indent + cursor.insertText(indent_text) + return True + return False + + @Slot() + def clear_all_output(self): + """Removes all output in the ipynb format (Json only)""" + try: + nb = nbformat.reads(self.toPlainText(), as_version=4) + if nb.cells: + for cell in nb.cells: + if 'outputs' in cell: + cell['outputs'] = [] + if 'prompt_number' in cell: + cell['prompt_number'] = None + # We do the following rather than using self.setPlainText + # to benefit from QTextEdit's undo/redo feature. + self.selectAll() + self.skip_rstrip = True + self.insertPlainText(nbformat.writes(nb)) + self.skip_rstrip = False + except Exception as e: + QMessageBox.critical(self, _('Removal error'), + _("It was not possible to remove outputs from " + "this notebook. The error is:\n\n") + \ + to_text_string(e)) + return + + @Slot() + def convert_notebook(self): + """Convert an IPython notebook to a Python script in editor""" + try: + nb = nbformat.reads(self.toPlainText(), as_version=4) + script = nbexporter().from_notebook_node(nb)[0] + except Exception as e: + QMessageBox.critical(self, _('Conversion error'), + _("It was not possible to convert this " + "notebook. The error is:\n\n") + \ + to_text_string(e)) + return + self.sig_new_file.emit(script) + + def indent(self, force=False): + """ + Indent current line or selection + + force=True: indent even if cursor is not a the beginning of the line + """ + leading_text = self.get_text('sol', 'cursor') + if self.has_selected_text(): + self.add_prefix(self.indent_chars) + elif (force or not leading_text.strip() or + (self.tab_indents and self.tab_mode)): + if self.is_python_like(): + if not self.fix_indent(forward=True): + self.add_prefix(self.indent_chars) + else: + self.add_prefix(self.indent_chars) + else: + if len(self.indent_chars) > 1: + length = len(self.indent_chars) + self.insert_text(" "*(length-(len(leading_text) % length))) + else: + self.insert_text(self.indent_chars) + + def indent_or_replace(self): + """Indent or replace by 4 spaces depending on selection and tab mode""" + if (self.tab_indents and self.tab_mode) or not self.has_selected_text(): + self.indent() + else: + cursor = self.textCursor() + if (self.get_selected_text() == + to_text_string(cursor.block().text())): + self.indent() + else: + cursor1 = self.textCursor() + cursor1.setPosition(cursor.selectionStart()) + cursor2 = self.textCursor() + cursor2.setPosition(cursor.selectionEnd()) + if cursor1.blockNumber() != cursor2.blockNumber(): + self.indent() + else: + self.replace(self.indent_chars) + + def unindent(self, force=False): + """ + Unindent current line or selection + + force=True: unindent even if cursor is not a the beginning of the line + """ + if self.has_selected_text(): + if self.indent_chars == "\t": + # Tabs, remove one tab + self.remove_prefix(self.indent_chars) + else: + # Spaces + space_count = len(self.indent_chars) + leading_spaces = self.__spaces_for_prefix() + remainder = leading_spaces % space_count + if remainder: + # Get block on "space multiple grid". + # See spyder-ide/spyder#5734. + self.remove_prefix(" "*remainder) + else: + # Unindent one space multiple + self.remove_prefix(self.indent_chars) + else: + leading_text = self.get_text('sol', 'cursor') + if (force or not leading_text.strip() or + (self.tab_indents and self.tab_mode)): + if self.is_python_like(): + if not self.fix_indent(forward=False): + self.remove_prefix(self.indent_chars) + elif leading_text.endswith('\t'): + self.remove_prefix('\t') + else: + self.remove_prefix(self.indent_chars) + + @Slot() + def toggle_comment(self): + """Toggle comment on current line or selection""" + cursor = self.textCursor() + start_pos, end_pos = sorted([cursor.selectionStart(), + cursor.selectionEnd()]) + cursor.setPosition(end_pos) + last_line = cursor.block().blockNumber() + if cursor.atBlockStart() and start_pos != end_pos: + last_line -= 1 + cursor.setPosition(start_pos) + first_line = cursor.block().blockNumber() + # If the selection contains only commented lines and surrounding + # whitespace, uncomment. Otherwise, comment. + is_comment_or_whitespace = True + at_least_one_comment = False + for _line_nb in range(first_line, last_line+1): + text = to_text_string(cursor.block().text()).lstrip() + is_comment = text.startswith(self.comment_string) + is_whitespace = (text == '') + is_comment_or_whitespace *= (is_comment or is_whitespace) + if is_comment: + at_least_one_comment = True + cursor.movePosition(QTextCursor.NextBlock) + if is_comment_or_whitespace and at_least_one_comment: + self.uncomment() + else: + self.comment() + + def is_comment(self, block): + """Detect inline comments. + + Return True if the block is an inline comment. + """ + if block is None: + return False + text = to_text_string(block.text()).lstrip() + return text.startswith(self.comment_string) + + def comment(self): + """Comment current line or selection.""" + self.add_prefix(self.comment_string + ' ') + + def uncomment(self): + """Uncomment current line or selection.""" + blockcomment = self.unblockcomment() + if not blockcomment: + self.remove_prefix(self.comment_string) + + def __blockcomment_bar(self, compatibility=False): + """Handle versions of blockcomment bar for backwards compatibility.""" + # Blockcomment bar in Spyder version >= 4 + blockcomment_bar = self.comment_string + ' ' + '=' * ( + 79 - len(self.comment_string + ' ')) + if compatibility: + # Blockcomment bar in Spyder version < 4 + blockcomment_bar = self.comment_string + '=' * ( + 79 - len(self.comment_string)) + return blockcomment_bar + + def transform_to_uppercase(self): + """Change to uppercase current line or selection.""" + cursor = self.textCursor() + prev_pos = cursor.position() + selected_text = to_text_string(cursor.selectedText()) + + if len(selected_text) == 0: + prev_pos = cursor.position() + cursor.select(QTextCursor.WordUnderCursor) + selected_text = to_text_string(cursor.selectedText()) + + s = selected_text.upper() + cursor.insertText(s) + self.set_cursor_position(prev_pos) + + def transform_to_lowercase(self): + """Change to lowercase current line or selection.""" + cursor = self.textCursor() + prev_pos = cursor.position() + selected_text = to_text_string(cursor.selectedText()) + + if len(selected_text) == 0: + prev_pos = cursor.position() + cursor.select(QTextCursor.WordUnderCursor) + selected_text = to_text_string(cursor.selectedText()) + + s = selected_text.lower() + cursor.insertText(s) + self.set_cursor_position(prev_pos) + + def blockcomment(self): + """Block comment current line or selection.""" + comline = self.__blockcomment_bar() + self.get_line_separator() + cursor = self.textCursor() + if self.has_selected_text(): + self.extend_selection_to_complete_lines() + start_pos, end_pos = cursor.selectionStart(), cursor.selectionEnd() + else: + start_pos = end_pos = cursor.position() + cursor.beginEditBlock() + cursor.setPosition(start_pos) + cursor.movePosition(QTextCursor.StartOfBlock) + while cursor.position() <= end_pos: + cursor.insertText(self.comment_string + " ") + cursor.movePosition(QTextCursor.EndOfBlock) + if cursor.atEnd(): + break + cursor.movePosition(QTextCursor.NextBlock) + end_pos += len(self.comment_string + " ") + cursor.setPosition(end_pos) + cursor.movePosition(QTextCursor.EndOfBlock) + if cursor.atEnd(): + cursor.insertText(self.get_line_separator()) + else: + cursor.movePosition(QTextCursor.NextBlock) + cursor.insertText(comline) + cursor.setPosition(start_pos) + cursor.movePosition(QTextCursor.StartOfBlock) + cursor.insertText(comline) + cursor.endEditBlock() + + def unblockcomment(self): + """Un-block comment current line or selection.""" + # Needed for backward compatibility with Spyder previous blockcomments. + # See spyder-ide/spyder#2845. + unblockcomment = self.__unblockcomment() + if not unblockcomment: + unblockcomment = self.__unblockcomment(compatibility=True) + else: + return unblockcomment + + def __unblockcomment(self, compatibility=False): + """Un-block comment current line or selection helper.""" + def __is_comment_bar(cursor): + return to_text_string(cursor.block().text() + ).startswith( + self.__blockcomment_bar(compatibility=compatibility)) + # Finding first comment bar + cursor1 = self.textCursor() + if __is_comment_bar(cursor1): + return + while not __is_comment_bar(cursor1): + cursor1.movePosition(QTextCursor.PreviousBlock) + if cursor1.blockNumber() == 0: + break + if not __is_comment_bar(cursor1): + return False + + def __in_block_comment(cursor): + cs = self.comment_string + return to_text_string(cursor.block().text()).startswith(cs) + # Finding second comment bar + cursor2 = QTextCursor(cursor1) + cursor2.movePosition(QTextCursor.NextBlock) + while not __is_comment_bar(cursor2) and __in_block_comment(cursor2): + cursor2.movePosition(QTextCursor.NextBlock) + if cursor2.block() == self.document().lastBlock(): + break + if not __is_comment_bar(cursor2): + return False + # Removing block comment + cursor3 = self.textCursor() + cursor3.beginEditBlock() + cursor3.setPosition(cursor1.position()) + cursor3.movePosition(QTextCursor.NextBlock) + while cursor3.position() < cursor2.position(): + cursor3.movePosition(QTextCursor.NextCharacter, + QTextCursor.KeepAnchor) + if not cursor3.atBlockEnd(): + # standard commenting inserts '# ' but a trailing space on an + # empty line might be stripped. + if not compatibility: + cursor3.movePosition(QTextCursor.NextCharacter, + QTextCursor.KeepAnchor) + cursor3.removeSelectedText() + cursor3.movePosition(QTextCursor.NextBlock) + for cursor in (cursor2, cursor1): + cursor3.setPosition(cursor.position()) + cursor3.select(QTextCursor.BlockUnderCursor) + cursor3.removeSelectedText() + cursor3.endEditBlock() + return True + + #------Kill ring handlers + # Taken from Jupyter's QtConsole + # Copyright (c) 2001-2015, IPython Development Team + # Copyright (c) 2015-, Jupyter Development Team + def kill_line_end(self): + """Kill the text on the current line from the cursor forward""" + cursor = self.textCursor() + cursor.clearSelection() + cursor.movePosition(QTextCursor.EndOfLine, QTextCursor.KeepAnchor) + if not cursor.hasSelection(): + # Line deletion + cursor.movePosition(QTextCursor.NextBlock, + QTextCursor.KeepAnchor) + self._kill_ring.kill_cursor(cursor) + self.setTextCursor(cursor) + + def kill_line_start(self): + """Kill the text on the current line from the cursor backward""" + cursor = self.textCursor() + cursor.clearSelection() + cursor.movePosition(QTextCursor.StartOfBlock, + QTextCursor.KeepAnchor) + self._kill_ring.kill_cursor(cursor) + self.setTextCursor(cursor) + + def _get_word_start_cursor(self, position): + """Find the start of the word to the left of the given position. If a + sequence of non-word characters precedes the first word, skip over + them. (This emulates the behavior of bash, emacs, etc.) + """ + document = self.document() + position -= 1 + while (position and not + self.is_letter_or_number(document.characterAt(position))): + position -= 1 + while position and self.is_letter_or_number( + document.characterAt(position)): + position -= 1 + cursor = self.textCursor() + cursor.setPosition(self.next_cursor_position()) + return cursor + + def _get_word_end_cursor(self, position): + """Find the end of the word to the right of the given position. If a + sequence of non-word characters precedes the first word, skip over + them. (This emulates the behavior of bash, emacs, etc.) + """ + document = self.document() + cursor = self.textCursor() + position = cursor.position() + cursor.movePosition(QTextCursor.End) + end = cursor.position() + while (position < end and + not self.is_letter_or_number(document.characterAt(position))): + position = self.next_cursor_position(position) + while (position < end and + self.is_letter_or_number(document.characterAt(position))): + position = self.next_cursor_position(position) + cursor.setPosition(position) + return cursor + + def kill_prev_word(self): + """Kill the previous word""" + position = self.textCursor().position() + cursor = self._get_word_start_cursor(position) + cursor.setPosition(position, QTextCursor.KeepAnchor) + self._kill_ring.kill_cursor(cursor) + self.setTextCursor(cursor) + + def kill_next_word(self): + """Kill the next word""" + position = self.textCursor().position() + cursor = self._get_word_end_cursor(position) + cursor.setPosition(position, QTextCursor.KeepAnchor) + self._kill_ring.kill_cursor(cursor) + self.setTextCursor(cursor) + + #------Autoinsertion of quotes/colons + def __get_current_color(self, cursor=None): + """Get the syntax highlighting color for the current cursor position""" + if cursor is None: + cursor = self.textCursor() + + block = cursor.block() + pos = cursor.position() - block.position() # relative pos within block + layout = block.layout() + block_formats = layout.additionalFormats() + + if block_formats: + # To easily grab current format for autoinsert_colons + if cursor.atBlockEnd(): + current_format = block_formats[-1].format + else: + current_format = None + for fmt in block_formats: + if (pos >= fmt.start) and (pos < fmt.start + fmt.length): + current_format = fmt.format + if current_format is None: + return None + color = current_format.foreground().color().name() + return color + else: + return None + + def in_comment_or_string(self, cursor=None, position=None): + """Is the cursor or position inside or next to a comment or string? + + If *cursor* is None, *position* is used instead. If *position* is also + None, then the current cursor position is used. + """ + if self.highlighter: + if cursor is None: + cursor = self.textCursor() + if position: + cursor.setPosition(position) + current_color = self.__get_current_color(cursor=cursor) + + comment_color = self.highlighter.get_color_name('comment') + string_color = self.highlighter.get_color_name('string') + if (current_color == comment_color) or (current_color == string_color): + return True + else: + return False + else: + return False + + def __colon_keyword(self, text): + stmt_kws = ['def', 'for', 'if', 'while', 'with', 'class', 'elif', + 'except'] + whole_kws = ['else', 'try', 'except', 'finally'] + text = text.lstrip() + words = text.split() + if any([text == wk for wk in whole_kws]): + return True + elif len(words) < 2: + return False + elif any([words[0] == sk for sk in stmt_kws]): + return True + else: + return False + + def __forbidden_colon_end_char(self, text): + end_chars = [':', '\\', '[', '{', '(', ','] + text = text.rstrip() + if any([text.endswith(c) for c in end_chars]): + return True + else: + return False + + def __has_colon_not_in_brackets(self, text): + """ + Return whether a string has a colon which is not between brackets. + This function returns True if the given string has a colon which is + not between a pair of (round, square or curly) brackets. It assumes + that the brackets in the string are balanced. + """ + bracket_ext = self.editor_extensions.get(CloseBracketsExtension) + for pos, char in enumerate(text): + if (char == ':' and + not bracket_ext.unmatched_brackets_in_line(text[:pos])): + return True + return False + + def __has_unmatched_opening_bracket(self): + """ + Checks if there are any unmatched opening brackets before the current + cursor position. + """ + position = self.textCursor().position() + for brace in [']', ')', '}']: + match = self.find_brace_match(position, brace, forward=False) + if match is not None: + return True + return False + + def autoinsert_colons(self): + """Decide if we want to autoinsert colons""" + bracket_ext = self.editor_extensions.get(CloseBracketsExtension) + self.completion_widget.hide() + line_text = self.get_text('sol', 'cursor') + if not self.textCursor().atBlockEnd(): + return False + elif self.in_comment_or_string(): + return False + elif not self.__colon_keyword(line_text): + return False + elif self.__forbidden_colon_end_char(line_text): + return False + elif bracket_ext.unmatched_brackets_in_line(line_text): + return False + elif self.__has_colon_not_in_brackets(line_text): + return False + elif self.__has_unmatched_opening_bracket(): + return False + else: + return True + + def next_char(self): + cursor = self.textCursor() + cursor.movePosition(QTextCursor.NextCharacter, + QTextCursor.KeepAnchor) + next_char = to_text_string(cursor.selectedText()) + return next_char + + def in_comment(self, cursor=None, position=None): + """Returns True if the given position is inside a comment. + + Parameters + ---------- + cursor : QTextCursor, optional + The position to check. + position : int, optional + The position to check if *cursor* is None. This parameter + is ignored when *cursor* is not None. + + If both *cursor* and *position* are none, then the position returned + by self.textCursor() is used instead. + """ + if self.highlighter: + if cursor is None: + cursor = self.textCursor() + if position is not None: + cursor.setPosition(position) + current_color = self.__get_current_color(cursor) + comment_color = self.highlighter.get_color_name('comment') + return (current_color == comment_color) + else: + return False + + def in_string(self, cursor=None, position=None): + """Returns True if the given position is inside a string. + + Parameters + ---------- + cursor : QTextCursor, optional + The position to check. + position : int, optional + The position to check if *cursor* is None. This parameter + is ignored when *cursor* is not None. + + If both *cursor* and *position* are none, then the position returned + by self.textCursor() is used instead. + """ + if self.highlighter: + if cursor is None: + cursor = self.textCursor() + if position is not None: + cursor.setPosition(position) + current_color = self.__get_current_color(cursor) + string_color = self.highlighter.get_color_name('string') + return (current_color == string_color) + else: + return False + + # ------ Qt Event handlers + def setup_context_menu(self): + """Setup context menu""" + self.undo_action = create_action( + self, _("Undo"), icon=ima.icon('undo'), + shortcut=CONF.get_shortcut('editor', 'undo'), triggered=self.undo) + self.redo_action = create_action( + self, _("Redo"), icon=ima.icon('redo'), + shortcut=CONF.get_shortcut('editor', 'redo'), triggered=self.redo) + self.cut_action = create_action( + self, _("Cut"), icon=ima.icon('editcut'), + shortcut=CONF.get_shortcut('editor', 'cut'), triggered=self.cut) + self.copy_action = create_action( + self, _("Copy"), icon=ima.icon('editcopy'), + shortcut=CONF.get_shortcut('editor', 'copy'), triggered=self.copy) + self.paste_action = create_action( + self, _("Paste"), icon=ima.icon('editpaste'), + shortcut=CONF.get_shortcut('editor', 'paste'), + triggered=self.paste) + selectall_action = create_action( + self, _("Select All"), icon=ima.icon('selectall'), + shortcut=CONF.get_shortcut('editor', 'select all'), + triggered=self.selectAll) + toggle_comment_action = create_action( + self, _("Comment")+"/"+_("Uncomment"), icon=ima.icon('comment'), + shortcut=CONF.get_shortcut('editor', 'toggle comment'), + triggered=self.toggle_comment) + self.clear_all_output_action = create_action( + self, _("Clear all ouput"), icon=ima.icon('ipython_console'), + triggered=self.clear_all_output) + self.ipynb_convert_action = create_action( + self, _("Convert to Python file"), icon=ima.icon('python'), + triggered=self.convert_notebook) + self.gotodef_action = create_action( + self, _("Go to definition"), + shortcut=CONF.get_shortcut('editor', 'go to definition'), + triggered=self.go_to_definition_from_cursor) + + self.inspect_current_object_action = create_action( + self, _("Inspect current object"), + icon=ima.icon('MessageBoxInformation'), + shortcut=CONF.get_shortcut('editor', 'inspect current object'), + triggered=self.sig_show_object_info.emit) + + # Run actions + self.run_cell_action = create_action( + self, _("Run cell"), icon=ima.icon('run_cell'), + shortcut=CONF.get_shortcut('editor', 'run cell'), + triggered=self.sig_run_cell) + self.run_cell_and_advance_action = create_action( + self, _("Run cell and advance"), icon=ima.icon('run_cell_advance'), + shortcut=CONF.get_shortcut('editor', 'run cell and advance'), + triggered=self.sig_run_cell_and_advance) + self.re_run_last_cell_action = create_action( + self, _("Re-run last cell"), + shortcut=CONF.get_shortcut('editor', 're-run last cell'), + triggered=self.sig_re_run_last_cell) + self.run_selection_action = create_action( + self, _("Run &selection or current line"), + icon=ima.icon('run_selection'), + shortcut=CONF.get_shortcut('editor', 'run selection'), + triggered=self.sig_run_selection) + self.run_to_line_action = create_action( + self, _("Run to current line"), + shortcut=CONF.get_shortcut('editor', 'run to line'), + triggered=self.sig_run_to_line) + self.run_from_line_action = create_action( + self, _("Run from current line"), + shortcut=CONF.get_shortcut('editor', 'run from line'), + triggered=self.sig_run_from_line) + self.debug_cell_action = create_action( + self, _("Debug cell"), icon=ima.icon('debug_cell'), + shortcut=CONF.get_shortcut('editor', 'debug cell'), + triggered=self.sig_debug_cell) + + # Zoom actions + zoom_in_action = create_action( + self, _("Zoom in"), icon=ima.icon('zoom_in'), + shortcut=QKeySequence(QKeySequence.ZoomIn), + triggered=self.zoom_in) + zoom_out_action = create_action( + self, _("Zoom out"), icon=ima.icon('zoom_out'), + shortcut=QKeySequence(QKeySequence.ZoomOut), + triggered=self.zoom_out) + zoom_reset_action = create_action( + self, _("Zoom reset"), shortcut=QKeySequence("Ctrl+0"), + triggered=self.zoom_reset) + + # Docstring + writer = self.writer_docstring + self.docstring_action = create_action( + self, _("Generate docstring"), + shortcut=CONF.get_shortcut('editor', 'docstring'), + triggered=writer.write_docstring_at_first_line_of_function) + + # Document formatting + formatter = CONF.get( + 'completions', + ('provider_configuration', 'lsp', 'values', 'formatting'), + '' + ) + self.format_action = create_action( + self, + _('Format file or selection with {0}').format( + formatter.capitalize()), + shortcut=CONF.get_shortcut('editor', 'autoformatting'), + triggered=self.format_document_or_range) + + self.format_action.setEnabled(False) + + # Build menu + self.menu = QMenu(self) + actions_1 = [self.run_cell_action, self.run_cell_and_advance_action, + self.re_run_last_cell_action, self.run_selection_action, + self.run_to_line_action, self.run_from_line_action, + self.gotodef_action, self.inspect_current_object_action, + None, self.undo_action, + self.redo_action, None, self.cut_action, + self.copy_action, self.paste_action, selectall_action] + actions_2 = [None, zoom_in_action, zoom_out_action, zoom_reset_action, + None, toggle_comment_action, self.docstring_action, + self.format_action] + if nbformat is not None: + nb_actions = [self.clear_all_output_action, + self.ipynb_convert_action, None] + actions = actions_1 + nb_actions + actions_2 + add_actions(self.menu, actions) + else: + actions = actions_1 + actions_2 + add_actions(self.menu, actions) + + # Read-only context-menu + self.readonly_menu = QMenu(self) + add_actions(self.readonly_menu, + (self.copy_action, None, selectall_action, + self.gotodef_action)) + + def keyReleaseEvent(self, event): + """Override Qt method.""" + self.sig_key_released.emit(event) + key = event.key() + direction_keys = {Qt.Key_Up, Qt.Key_Left, Qt.Key_Right, Qt.Key_Down} + if key in direction_keys: + self.request_cursor_event() + + # Update decorations after releasing these keys because they don't + # trigger the emission of the valueChanged signal in + # verticalScrollBar. + # See https://bugreports.qt.io/browse/QTBUG-25365 + if key in {Qt.Key_Up, Qt.Key_Down}: + self.update_decorations_timer.start() + + # This necessary to run our Pygments highlighter again after the + # user generated text changes + if event.text(): + # Stop the active timer and start it again to not run it on + # every event + if self.timer_syntax_highlight.isActive(): + self.timer_syntax_highlight.stop() + + # Adjust interval to rehighlight according to the lines + # present in the file + total_lines = self.get_line_count() + if total_lines < 1000: + self.timer_syntax_highlight.setInterval(600) + elif total_lines < 2000: + self.timer_syntax_highlight.setInterval(800) + else: + self.timer_syntax_highlight.setInterval(1000) + self.timer_syntax_highlight.start() + + self._restore_editor_cursor_and_selections() + super(CodeEditor, self).keyReleaseEvent(event) + event.ignore() + + def event(self, event): + """Qt method override.""" + if event.type() == QEvent.ShortcutOverride: + event.ignore() + return False + else: + return super(CodeEditor, self).event(event) + + def _start_completion_timer(self): + """Helper to start timer for automatic completions or handle them.""" + if not self.automatic_completions: + return + + if self.automatic_completions_after_ms > 0: + self._timer_autocomplete.start( + self.automatic_completions_after_ms) + else: + self._handle_completions() + + def _handle_keypress_event(self, event): + """Handle keypress events.""" + TextEditBaseWidget.keyPressEvent(self, event) + + # Trigger the following actions only if the event generates + # a text change. + text = to_text_string(event.text()) + if text: + # The next three lines are a workaround for a quirk of + # QTextEdit on Linux with Qt < 5.15, MacOs and Windows. + # See spyder-ide/spyder#12663 and + # https://bugreports.qt.io/browse/QTBUG-35861 + if (parse_version(QT_VERSION) < parse_version('5.15') + or os.name == 'nt' or sys.platform == 'darwin'): + cursor = self.textCursor() + cursor.setPosition(cursor.position()) + self.setTextCursor(cursor) + self.sig_text_was_inserted.emit() + + def keyPressEvent(self, event): + """Reimplement Qt method.""" + tab_pressed = False + if self.completions_hint_after_ms > 0: + self._completions_hint_idle = False + self._timer_completions_hint.start(self.completions_hint_after_ms) + else: + self._set_completions_hint_idle() + + # Send the signal to the editor's extension. + event.ignore() + self.sig_key_pressed.emit(event) + + self.kite_call_to_action.handle_key_press(event) + + key = event.key() + text = to_text_string(event.text()) + has_selection = self.has_selected_text() + ctrl = event.modifiers() & Qt.ControlModifier + shift = event.modifiers() & Qt.ShiftModifier + + if text: + self.__clear_occurrences() + + # Only ask for completions if there's some text generated + # as part of the event. Events such as pressing Crtl, + # Shift or Alt don't generate any text. + # Fixes spyder-ide/spyder#11021 + self._start_completion_timer() + + if event.modifiers() and self.is_completion_widget_visible(): + # Hide completion widget before passing event modifiers + # since the keypress could be then a shortcut + # See spyder-ide/spyder#14806 + self.completion_widget.hide() + + if key in {Qt.Key_Up, Qt.Key_Left, Qt.Key_Right, Qt.Key_Down}: + self.hide_tooltip() + + if event.isAccepted(): + # The event was handled by one of the editor extension. + return + + if key in [Qt.Key_Control, Qt.Key_Shift, Qt.Key_Alt, + Qt.Key_Meta, Qt.KeypadModifier]: + # The user pressed only a modifier key. + if ctrl: + pos = self.mapFromGlobal(QCursor.pos()) + pos = self.calculate_real_position_from_global(pos) + if self._handle_goto_uri_event(pos): + event.accept() + return + + if self._handle_goto_definition_event(pos): + event.accept() + return + return + + # ---- Handle hard coded and builtin actions + operators = {'+', '-', '*', '**', '/', '//', '%', '@', '<<', '>>', + '&', '|', '^', '~', '<', '>', '<=', '>=', '==', '!='} + delimiters = {',', ':', ';', '@', '=', '->', '+=', '-=', '*=', '/=', + '//=', '%=', '@=', '&=', '|=', '^=', '>>=', '<<=', '**='} + + if text not in self.auto_completion_characters: + if text in operators or text in delimiters: + self.completion_widget.hide() + if key in (Qt.Key_Enter, Qt.Key_Return): + if not shift and not ctrl: + if (self.add_colons_enabled and self.is_python_like() and + self.autoinsert_colons()): + self.textCursor().beginEditBlock() + self.insert_text(':' + self.get_line_separator()) + if self.strip_trailing_spaces_on_modify: + self.fix_and_strip_indent() + else: + self.fix_indent() + self.textCursor().endEditBlock() + elif self.is_completion_widget_visible(): + self.select_completion_list() + else: + self.textCursor().beginEditBlock() + cur_indent = self.get_block_indentation( + self.textCursor().blockNumber()) + self._handle_keypress_event(event) + # Check if we're in a comment or a string at the + # current position + cmt_or_str_cursor = self.in_comment_or_string() + + # Check if the line start with a comment or string + cursor = self.textCursor() + cursor.setPosition(cursor.block().position(), + QTextCursor.KeepAnchor) + cmt_or_str_line_begin = self.in_comment_or_string( + cursor=cursor) + + # Check if we are in a comment or a string + cmt_or_str = cmt_or_str_cursor and cmt_or_str_line_begin + + if self.strip_trailing_spaces_on_modify: + self.fix_and_strip_indent( + comment_or_string=cmt_or_str, + cur_indent=cur_indent) + else: + self.fix_indent(comment_or_string=cmt_or_str, + cur_indent=cur_indent) + self.textCursor().endEditBlock() + elif key == Qt.Key_Insert and not shift and not ctrl: + self.setOverwriteMode(not self.overwriteMode()) + elif key == Qt.Key_Backspace and not shift and not ctrl: + if has_selection or not self.intelligent_backspace: + self._handle_keypress_event(event) + else: + leading_text = self.get_text('sol', 'cursor') + leading_length = len(leading_text) + trailing_spaces = leading_length - len(leading_text.rstrip()) + trailing_text = self.get_text('cursor', 'eol') + matches = ('()', '[]', '{}', '\'\'', '""') + if (not leading_text.strip() and + (leading_length > len(self.indent_chars))): + if leading_length % len(self.indent_chars) == 0: + self.unindent() + else: + self._handle_keypress_event(event) + elif trailing_spaces and not trailing_text.strip(): + self.remove_suffix(leading_text[-trailing_spaces:]) + elif (leading_text and trailing_text and + (leading_text[-1] + trailing_text[0] in matches)): + cursor = self.textCursor() + cursor.movePosition(QTextCursor.PreviousCharacter) + cursor.movePosition(QTextCursor.NextCharacter, + QTextCursor.KeepAnchor, 2) + cursor.removeSelectedText() + else: + self._handle_keypress_event(event) + elif key == Qt.Key_Home: + self.stdkey_home(shift, ctrl) + elif key == Qt.Key_End: + # See spyder-ide/spyder#495: on MacOS X, it is necessary to + # redefine this basic action which should have been implemented + # natively + self.stdkey_end(shift, ctrl) + elif (text in self.auto_completion_characters and + self.automatic_completions): + self.insert_text(text) + if text == ".": + if not self.in_comment_or_string(): + text = self.get_text('sol', 'cursor') + last_obj = getobj(text) + prev_char = text[-2] if len(text) > 1 else '' + if (prev_char in {')', ']', '}'} or + (last_obj and not last_obj.isdigit())): + # Completions should be triggered immediately when + # an autocompletion character is introduced. + self.do_completion(automatic=True) + else: + self.do_completion(automatic=True) + elif (text in self.signature_completion_characters and + not self.has_selected_text()): + self.insert_text(text) + self.request_signature() + elif (key == Qt.Key_Colon and not has_selection and + self.auto_unindent_enabled): + leading_text = self.get_text('sol', 'cursor') + if leading_text.lstrip() in ('else', 'finally'): + ind = lambda txt: len(txt) - len(txt.lstrip()) + prevtxt = (to_text_string(self.textCursor().block(). + previous().text())) + if self.language == 'Python': + prevtxt = prevtxt.rstrip() + if ind(leading_text) == ind(prevtxt): + self.unindent(force=True) + self._handle_keypress_event(event) + elif (key == Qt.Key_Space and not shift and not ctrl and not + has_selection and self.auto_unindent_enabled): + self.completion_widget.hide() + leading_text = self.get_text('sol', 'cursor') + if leading_text.lstrip() in ('elif', 'except'): + ind = lambda txt: len(txt)-len(txt.lstrip()) + prevtxt = (to_text_string(self.textCursor().block(). + previous().text())) + if self.language == 'Python': + prevtxt = prevtxt.rstrip() + if ind(leading_text) == ind(prevtxt): + self.unindent(force=True) + self._handle_keypress_event(event) + elif key == Qt.Key_Tab and not ctrl: + # Important note: can't be called with a QShortcut because + # of its singular role with respect to widget focus management + tab_pressed = True + if not has_selection and not self.tab_mode: + self.intelligent_tab() + else: + # indent the selected text + self.indent_or_replace() + elif key == Qt.Key_Backtab and not ctrl: + # Backtab, i.e. Shift+, could be treated as a QShortcut but + # there is no point since can't (see above) + tab_pressed = True + if not has_selection and not self.tab_mode: + self.intelligent_backtab() + else: + # indent the selected text + self.unindent() + event.accept() + elif not event.isAccepted(): + self._handle_keypress_event(event) + + self._last_key_pressed_text = text + self._last_pressed_key = key + if self.automatic_completions_after_ms == 0 and not tab_pressed: + self._handle_completions() + + if not event.modifiers(): + # Accept event to avoid it being handled by the parent. + # Modifiers should be passed to the parent because they + # could be shortcuts + event.accept() + + def _handle_completions(self): + """Handle on the fly completions after delay.""" + if not self.automatic_completions: + return + + cursor = self.textCursor() + pos = cursor.position() + cursor.select(QTextCursor.WordUnderCursor) + text = to_text_string(cursor.selectedText()) + + key = self._last_pressed_key + if key is not None: + if key in [Qt.Key_Return, Qt.Key_Escape, + Qt.Key_Tab, Qt.Key_Backtab, Qt.Key_Space]: + self._last_pressed_key = None + return + + # Correctly handle completions when Backspace key is pressed. + # We should not show the widget if deleting a space before a word. + if key == Qt.Key_Backspace: + cursor.setPosition(pos - 1, QTextCursor.MoveAnchor) + cursor.select(QTextCursor.WordUnderCursor) + prev_text = to_text_string(cursor.selectedText()) + cursor.setPosition(pos - 1, QTextCursor.MoveAnchor) + cursor.setPosition(pos, QTextCursor.KeepAnchor) + prev_char = cursor.selectedText() + if prev_text == '' or prev_char in (u'\u2029', ' ', '\t'): + return + + # Text might be after a dot '.' + if text == '': + cursor.setPosition(pos - 1, QTextCursor.MoveAnchor) + cursor.select(QTextCursor.WordUnderCursor) + text = to_text_string(cursor.selectedText()) + if text != '.': + text = '' + + # WordUnderCursor fails if the cursor is next to a right brace. + # If the returned text starts with it, we move to the left. + if text.startswith((')', ']', '}')): + cursor.setPosition(pos - 1, QTextCursor.MoveAnchor) + cursor.select(QTextCursor.WordUnderCursor) + text = to_text_string(cursor.selectedText()) + + is_backspace = ( + self.is_completion_widget_visible() and key == Qt.Key_Backspace) + + if (len(text) >= self.automatic_completions_after_chars + and self._last_key_pressed_text or is_backspace): + # Perform completion on the fly + if not self.in_comment_or_string(): + # Variables can include numbers and underscores + if (text.isalpha() or text.isalnum() or '_' in text + or '.' in text): + self.do_completion(automatic=True) + self._last_key_pressed_text = '' + self._last_pressed_key = None + + def fix_and_strip_indent(self, *args, **kwargs): + """ + Automatically fix indent and strip previous automatic indent. + + args and kwargs are forwarded to self.fix_indent + """ + # Fix indent + cursor_before = self.textCursor().position() + # A change just occurred on the last line (return was pressed) + if cursor_before > 0: + self.last_change_position = cursor_before - 1 + self.fix_indent(*args, **kwargs) + cursor_after = self.textCursor().position() + # Remove previous spaces and update last_auto_indent + nspaces_removed = self.strip_trailing_spaces() + self.last_auto_indent = (cursor_before - nspaces_removed, + cursor_after - nspaces_removed) + + def run_pygments_highlighter(self): + """Run pygments highlighter.""" + if isinstance(self.highlighter, sh.PygmentsSH): + self.highlighter.make_charlist() + + def get_pattern_at(self, coordinates): + """ + Return key, text and cursor for pattern (if found at coordinates). + """ + return self.get_pattern_cursor_at(self.highlighter.patterns, + coordinates) + + def get_pattern_cursor_at(self, pattern, coordinates): + """ + Find pattern located at the line where the coordinate is located. + + This returns the actual match and the cursor that selects the text. + """ + cursor, key, text = None, None, None + break_loop = False + + # Check if the pattern is in line + line = self.get_line_at(coordinates) + + for match in pattern.finditer(line): + for key, value in list(match.groupdict().items()): + if value: + start, end = sh.get_span(match) + + # Get cursor selection if pattern found + cursor = self.cursorForPosition(coordinates) + cursor.movePosition(QTextCursor.StartOfBlock) + line_start_position = cursor.position() + + cursor.setPosition(line_start_position + start, + cursor.MoveAnchor) + start_rect = self.cursorRect(cursor) + cursor.setPosition(line_start_position + end, + cursor.MoveAnchor) + end_rect = self.cursorRect(cursor) + bounding_rect = start_rect.united(end_rect) + + # Check coordinates are located within the selection rect + if bounding_rect.contains(coordinates): + text = line[start:end] + cursor.setPosition(line_start_position + start, + cursor.KeepAnchor) + break_loop = True + break + + if break_loop: + break + + return key, text, cursor + + def _preprocess_file_uri(self, uri): + """Format uri to conform to absolute or relative file paths.""" + fname = uri.replace('file://', '') + if fname[-1] == '/': + fname = fname[:-1] + + # ^/ is used to denote the current project root + if fname.startswith("^/"): + if self.current_project_path is not None: + fname = osp.join(self.current_project_path, fname[2:]) + else: + fname = fname.replace("^/", "~/") + + if fname.startswith("~/"): + fname = osp.expanduser(fname) + + dirname = osp.dirname(osp.abspath(self.filename)) + if osp.isdir(dirname): + if not osp.isfile(fname): + # Maybe relative + fname = osp.join(dirname, fname) + + self.sig_file_uri_preprocessed.emit(fname) + + return fname + + def _handle_goto_definition_event(self, pos): + """Check if goto definition can be applied and apply highlight.""" + text = self.get_word_at(pos) + if text and not sourcecode.is_keyword(to_text_string(text)): + if not self.__cursor_changed: + QApplication.setOverrideCursor(QCursor(Qt.PointingHandCursor)) + self.__cursor_changed = True + cursor = self.cursorForPosition(pos) + cursor.select(QTextCursor.WordUnderCursor) + self.clear_extra_selections('ctrl_click') + self.highlight_selection( + 'ctrl_click', cursor, + foreground_color=self.ctrl_click_color, + underline_color=self.ctrl_click_color, + underline_style=QTextCharFormat.SingleUnderline) + return True + else: + return False + + def _handle_goto_uri_event(self, pos): + """Check if go to uri can be applied and apply highlight.""" + key, pattern_text, cursor = self.get_pattern_at(pos) + if key and pattern_text and cursor: + self._last_hover_pattern_key = key + self._last_hover_pattern_text = pattern_text + + color = self.ctrl_click_color + + if key in ['file']: + fname = self._preprocess_file_uri(pattern_text) + if not osp.isfile(fname): + color = QColor(SpyderPalette.COLOR_ERROR_2) + + self.clear_extra_selections('ctrl_click') + self.highlight_selection( + 'ctrl_click', cursor, + foreground_color=color, + underline_color=color, + underline_style=QTextCharFormat.SingleUnderline) + + if not self.__cursor_changed: + QApplication.setOverrideCursor( + QCursor(Qt.PointingHandCursor)) + self.__cursor_changed = True + + self.sig_uri_found.emit(pattern_text) + return True + else: + self._last_hover_pattern_key = key + self._last_hover_pattern_text = pattern_text + return False + + def go_to_uri_from_cursor(self, uri): + """Go to url from cursor and defined hover patterns.""" + key = self._last_hover_pattern_key + full_uri = uri + + if key in ['file']: + fname = self._preprocess_file_uri(uri) + + if osp.isfile(fname) and encoding.is_text_file(fname): + # Open in editor + self.go_to_definition.emit(fname, 0, 0) + else: + # Use external program + fname = file_uri(fname) + start_file(fname) + elif key in ['mail', 'url']: + if '@' in uri and not uri.startswith('mailto:'): + full_uri = 'mailto:' + uri + quri = QUrl(full_uri) + QDesktopServices.openUrl(quri) + elif key in ['issue']: + # Issue URI + repo_url = uri.replace('#', '/issues/') + if uri.startswith(('gh-', 'bb-', 'gl-')): + number = uri[3:] + remotes = get_git_remotes(self.filename) + remote = remotes.get('upstream', remotes.get('origin')) + if remote: + full_uri = remote_to_url(remote) + '/issues/' + number + else: + full_uri = None + elif uri.startswith('gh:') or ':' not in uri: + # Github + repo_and_issue = repo_url + if uri.startswith('gh:'): + repo_and_issue = repo_url[3:] + full_uri = 'https://github.com/' + repo_and_issue + elif uri.startswith('gl:'): + # Gitlab + full_uri = 'https://gitlab.com/' + repo_url[3:] + elif uri.startswith('bb:'): + # Bitbucket + full_uri = 'https://bitbucket.org/' + repo_url[3:] + + if full_uri: + quri = QUrl(full_uri) + QDesktopServices.openUrl(quri) + else: + QMessageBox.information( + self, + _('Information'), + _('This file is not part of a local repository or ' + 'upstream/origin remotes are not defined!'), + QMessageBox.Ok, + ) + self.hide_tooltip() + return full_uri + + def line_range(self, position): + """ + Get line range from position. + """ + if position is None: + return None + if position >= self.document().characterCount(): + return None + # Check if still on the line + cursor = self.textCursor() + cursor.setPosition(position) + line_range = (cursor.block().position(), + cursor.block().position() + + cursor.block().length() - 1) + return line_range + + def strip_trailing_spaces(self): + """ + Strip trailing spaces if needed. + + Remove trailing whitespace on leaving a non-string line containing it. + Return the number of removed spaces. + """ + if not running_under_pytest(): + if not self.hasFocus(): + # Avoid problem when using split editor + return 0 + # Update current position + current_position = self.textCursor().position() + last_position = self.last_position + self.last_position = current_position + + if self.skip_rstrip: + return 0 + + line_range = self.line_range(last_position) + if line_range is None: + # Doesn't apply + return 0 + + def pos_in_line(pos): + """Check if pos is in last line.""" + if pos is None: + return False + return line_range[0] <= pos <= line_range[1] + + if pos_in_line(current_position): + # Check if still on the line + return 0 + + # Check if end of line in string + cursor = self.textCursor() + cursor.setPosition(line_range[1]) + + if (not self.strip_trailing_spaces_on_modify + or self.in_string(cursor=cursor)): + if self.last_auto_indent is None: + return 0 + elif (self.last_auto_indent != + self.line_range(self.last_auto_indent[0])): + # line not empty + self.last_auto_indent = None + return 0 + line_range = self.last_auto_indent + self.last_auto_indent = None + elif not pos_in_line(self.last_change_position): + # Should process if pressed return or made a change on the line: + return 0 + + cursor.setPosition(line_range[0]) + cursor.setPosition(line_range[1], + QTextCursor.KeepAnchor) + # remove spaces on the right + text = cursor.selectedText() + strip = text.rstrip() + # I think all the characters we can strip are in a single QChar. + # Therefore there shouldn't be any length problems. + N_strip = qstring_length(text[len(strip):]) + + if N_strip > 0: + # Select text to remove + cursor.setPosition(line_range[1] - N_strip) + cursor.setPosition(line_range[1], + QTextCursor.KeepAnchor) + cursor.removeSelectedText() + # Correct last change position + self.last_change_position = line_range[1] + self.last_position = self.textCursor().position() + return N_strip + return 0 + + def move_line_up(self): + """Move up current line or selected text""" + self.__move_line_or_selection(after_current_line=False) + + def move_line_down(self): + """Move down current line or selected text""" + self.__move_line_or_selection(after_current_line=True) + + def __move_line_or_selection(self, after_current_line=True): + cursor = self.textCursor() + # Unfold any folded code block before moving lines up/down + folding_panel = self.panels.get('FoldingPanel') + fold_start_line = cursor.blockNumber() + 1 + block = cursor.block().next() + + if fold_start_line in folding_panel.folding_status: + fold_status = folding_panel.folding_status[fold_start_line] + if fold_status: + folding_panel.toggle_fold_trigger(block) + + if after_current_line: + # Unfold any folded region when moving lines down + fold_start_line = cursor.blockNumber() + 2 + block = cursor.block().next().next() + + if fold_start_line in folding_panel.folding_status: + fold_status = folding_panel.folding_status[fold_start_line] + if fold_status: + folding_panel.toggle_fold_trigger(block) + else: + # Unfold any folded region when moving lines up + block = cursor.block() + offset = 0 + if self.has_selected_text(): + ((selection_start, _), + (selection_end)) = self.get_selection_start_end() + if selection_end != selection_start: + offset = 1 + fold_start_line = block.blockNumber() - 1 - offset + + # Find the innermost code folding region for the current position + enclosing_regions = sorted(list( + folding_panel.current_tree[fold_start_line])) + + folding_status = folding_panel.folding_status + if len(enclosing_regions) > 0: + for region in enclosing_regions: + fold_start_line = region.begin + block = self.document().findBlockByNumber(fold_start_line) + if fold_start_line in folding_status: + fold_status = folding_status[fold_start_line] + if fold_status: + folding_panel.toggle_fold_trigger(block) + + self._TextEditBaseWidget__move_line_or_selection( + after_current_line=after_current_line) + + def mouseMoveEvent(self, event): + """Underline words when pressing """ + # Restart timer every time the mouse is moved + # This is needed to correctly handle hover hints with a delay + self._timer_mouse_moving.start() + + pos = event.pos() + self._last_point = pos + alt = event.modifiers() & Qt.AltModifier + ctrl = event.modifiers() & Qt.ControlModifier + + if alt: + self.sig_alt_mouse_moved.emit(event) + event.accept() + return + + if ctrl: + if self._handle_goto_uri_event(pos): + event.accept() + return + + if self.has_selected_text(): + TextEditBaseWidget.mouseMoveEvent(self, event) + return + + if self.go_to_definition_enabled and ctrl: + if self._handle_goto_definition_event(pos): + event.accept() + return + + if self.__cursor_changed: + self._restore_editor_cursor_and_selections() + else: + if (not self._should_display_hover(pos) + and not self.is_completion_widget_visible()): + self.hide_tooltip() + + TextEditBaseWidget.mouseMoveEvent(self, event) + + def setPlainText(self, txt): + """ + Extends setPlainText to emit the new_text_set signal. + + :param txt: The new text to set. + :param mime_type: Associated mimetype. Setting the mime will update the + pygments lexer. + :param encoding: text encoding + """ + super(CodeEditor, self).setPlainText(txt) + self.new_text_set.emit() + + def focusOutEvent(self, event): + """Extend Qt method""" + self.sig_focus_changed.emit() + self._restore_editor_cursor_and_selections() + super(CodeEditor, self).focusOutEvent(event) + + def focusInEvent(self, event): + formatting_enabled = getattr(self, 'formatting_enabled', False) + self.sig_refresh_formatting.emit(formatting_enabled) + super(CodeEditor, self).focusInEvent(event) + + def leaveEvent(self, event): + """Extend Qt method""" + self.sig_leave_out.emit() + self._restore_editor_cursor_and_selections() + TextEditBaseWidget.leaveEvent(self, event) + + def mousePressEvent(self, event): + """Override Qt method.""" + self.hide_tooltip() + self.kite_call_to_action.handle_mouse_press(event) + + ctrl = event.modifiers() & Qt.ControlModifier + alt = event.modifiers() & Qt.AltModifier + pos = event.pos() + self._mouse_left_button_pressed = event.button() == Qt.LeftButton + + if event.button() == Qt.LeftButton and ctrl: + TextEditBaseWidget.mousePressEvent(self, event) + cursor = self.cursorForPosition(pos) + uri = self._last_hover_pattern_text + if uri: + self.go_to_uri_from_cursor(uri) + else: + self.go_to_definition_from_cursor(cursor) + elif event.button() == Qt.LeftButton and alt: + self.sig_alt_left_mouse_pressed.emit(event) + else: + TextEditBaseWidget.mousePressEvent(self, event) + + def mouseReleaseEvent(self, event): + """Override Qt method.""" + if event.button() == Qt.LeftButton: + self._mouse_left_button_pressed = False + + self.request_cursor_event() + TextEditBaseWidget.mouseReleaseEvent(self, event) + + def contextMenuEvent(self, event): + """Reimplement Qt method""" + nonempty_selection = self.has_selected_text() + self.copy_action.setEnabled(nonempty_selection) + self.cut_action.setEnabled(nonempty_selection) + self.clear_all_output_action.setVisible(self.is_json() and + nbformat is not None) + self.ipynb_convert_action.setVisible(self.is_json() and + nbformat is not None) + self.run_cell_action.setVisible(self.is_python_or_ipython()) + self.run_cell_and_advance_action.setVisible(self.is_python_or_ipython()) + self.run_selection_action.setVisible(self.is_python_or_ipython()) + self.run_to_line_action.setVisible(self.is_python_or_ipython()) + self.run_from_line_action.setVisible(self.is_python_or_ipython()) + self.re_run_last_cell_action.setVisible(self.is_python_or_ipython()) + self.gotodef_action.setVisible(self.go_to_definition_enabled) + + formatter = CONF.get( + 'completions', + ('provider_configuration', 'lsp', 'values', 'formatting'), + '' + ) + self.format_action.setText(_( + 'Format file or selection with {0}').format( + formatter.capitalize())) + + # Check if a docstring is writable + writer = self.writer_docstring + writer.line_number_cursor = self.get_line_number_at(event.pos()) + result = writer.get_function_definition_from_first_line() + + if result: + self.docstring_action.setEnabled(True) + else: + self.docstring_action.setEnabled(False) + + # Code duplication go_to_definition_from_cursor and mouse_move_event + cursor = self.textCursor() + text = to_text_string(cursor.selectedText()) + if len(text) == 0: + cursor.select(QTextCursor.WordUnderCursor) + text = to_text_string(cursor.selectedText()) + + self.undo_action.setEnabled(self.document().isUndoAvailable()) + self.redo_action.setEnabled(self.document().isRedoAvailable()) + menu = self.menu + if self.isReadOnly(): + menu = self.readonly_menu + menu.popup(event.globalPos()) + event.accept() + + def _restore_editor_cursor_and_selections(self): + """Restore the cursor and extra selections of this code editor.""" + if self.__cursor_changed: + self.__cursor_changed = False + QApplication.restoreOverrideCursor() + self.clear_extra_selections('ctrl_click') + self._last_hover_pattern_key = None + self._last_hover_pattern_text = None + + #------ Drag and drop + def dragEnterEvent(self, event): + """ + Reimplemented Qt method. + + Inform Qt about the types of data that the widget accepts. + """ + logger.debug("dragEnterEvent was received") + all_urls = mimedata2url(event.mimeData()) + if all_urls: + # Let the parent widget handle this + logger.debug("Let the parent widget handle this dragEnterEvent") + event.ignore() + else: + logger.debug("Call TextEditBaseWidget dragEnterEvent method") + TextEditBaseWidget.dragEnterEvent(self, event) + + def dropEvent(self, event): + """ + Reimplemented Qt method. + + Unpack dropped data and handle it. + """ + logger.debug("dropEvent was received") + if mimedata2url(event.mimeData()): + logger.debug("Let the parent widget handle this") + event.ignore() + else: + logger.debug("Call TextEditBaseWidget dropEvent method") + TextEditBaseWidget.dropEvent(self, event) + + #------ Paint event + def paintEvent(self, event): + """Overrides paint event to update the list of visible blocks""" + self.update_visible_blocks(event) + TextEditBaseWidget.paintEvent(self, event) + self.painted.emit(event) + + def update_visible_blocks(self, event): + """Update the list of visible blocks/lines position""" + self.__visible_blocks[:] = [] + block = self.firstVisibleBlock() + blockNumber = block.blockNumber() + top = int(self.blockBoundingGeometry(block).translated( + self.contentOffset()).top()) + bottom = top + int(self.blockBoundingRect(block).height()) + ebottom_bottom = self.height() + + while block.isValid(): + visible = bottom <= ebottom_bottom + if not visible: + break + if block.isVisible(): + self.__visible_blocks.append((top, blockNumber+1, block)) + block = block.next() + top = bottom + bottom = top + int(self.blockBoundingRect(block).height()) + blockNumber = block.blockNumber() + + def _draw_editor_cell_divider(self): + """Draw a line on top of a define cell""" + if self.supported_cell_language: + cell_line_color = self.comment_color + painter = QPainter(self.viewport()) + pen = painter.pen() + pen.setStyle(Qt.SolidLine) + pen.setBrush(cell_line_color) + painter.setPen(pen) + + for top, line_number, block in self.visible_blocks: + if is_cell_header(block): + painter.drawLine(4, top, self.width(), top) + + @property + def visible_blocks(self): + """ + Returns the list of visible blocks. + + Each element in the list is a tuple made up of the line top position, + the line number (already 1 based), and the QTextBlock itself. + + :return: A list of tuple(top position, line number, block) + :rtype: List of tuple(int, int, QtGui.QTextBlock) + """ + return self.__visible_blocks + + def is_editor(self): + return True + + def popup_docstring(self, prev_text, prev_pos): + """Show the menu for generating docstring.""" + line_text = self.textCursor().block().text() + if line_text != prev_text: + return + + if prev_pos != self.textCursor().position(): + return + + writer = self.writer_docstring + if writer.get_function_definition_from_below_last_line(): + point = self.cursorRect().bottomRight() + point = self.calculate_real_position(point) + point = self.mapToGlobal(point) + + self.menu_docstring = QMenuOnlyForEnter(self) + self.docstring_action = create_action( + self, _("Generate docstring"), icon=ima.icon('TextFileIcon'), + triggered=writer.write_docstring) + self.menu_docstring.addAction(self.docstring_action) + self.menu_docstring.setActiveAction(self.docstring_action) + self.menu_docstring.popup(point) + + def delayed_popup_docstring(self): + """Show context menu for docstring. + + This method is called after typing '''. After typing ''', this function + waits 300ms. If there was no input for 300ms, show the context menu. + """ + line_text = self.textCursor().block().text() + pos = self.textCursor().position() + + timer = QTimer() + timer.singleShot(300, lambda: self.popup_docstring(line_text, pos)) + + def set_current_project_path(self, root_path=None): + """ + Set the current active project root path. + + Parameters + ---------- + root_path: str or None, optional + Path to current project root path. Default is None. + """ + self.current_project_path = root_path + + def count_leading_empty_lines(self, cell): + """Count the number of leading empty cells.""" + lines = cell.splitlines(keepends=True) + if not lines: + return 0 + for i, line in enumerate(lines): + if line and not line.isspace(): + return i + return len(lines) + + def ipython_to_python(self, code): + """Transform IPython code to python code.""" + tm = TransformerManager() + number_empty_lines = self.count_leading_empty_lines(code) + try: + code = tm.transform_cell(code) + except SyntaxError: + return code + return '\n' * number_empty_lines + code + + def is_letter_or_number(self, char): + """ + Returns whether the specified unicode character is a letter or a + number. + """ + cat = category(char) + return cat.startswith('L') or cat.startswith('N') + + +# ============================================================================= +# Editor + Class browser test +# ============================================================================= +class TestWidget(QSplitter): + def __init__(self, parent): + QSplitter.__init__(self, parent) + self.editor = CodeEditor(self) + self.editor.setup_editor(linenumbers=True, markers=True, tab_mode=False, + font=QFont("Courier New", 10), + show_blanks=True, color_scheme='Zenburn') + self.addWidget(self.editor) + self.setWindowIcon(ima.icon('spyder')) + + def load(self, filename): + self.editor.set_text_from_file(filename) + self.setWindowTitle("%s - %s (%s)" % (_("Editor"), + osp.basename(filename), + osp.dirname(filename))) + self.editor.hide_tooltip() + + +def test(fname): + from spyder.utils.qthelpers import qapplication + app = qapplication(test_time=5) + win = TestWidget(None) + win.show() + win.load(fname) + win.resize(900, 700) + sys.exit(app.exec_()) + + +if __name__ == '__main__': + if len(sys.argv) > 1: + fname = sys.argv[1] + else: + fname = __file__ + test(fname) diff --git a/spyder/plugins/editor/widgets/editor.py b/spyder/plugins/editor/widgets/editor.py index c4e02ae0c0a..5b72d994f92 100644 --- a/spyder/plugins/editor/widgets/editor.py +++ b/spyder/plugins/editor/widgets/editor.py @@ -1,3611 +1,3611 @@ -# -*- coding: utf-8 -*- -# -# Copyright © Spyder Project Contributors -# Licensed under the terms of the MIT License -# (see spyder/__init__.py for details) - -"""Editor Widget""" - -# pylint: disable=C0103 -# pylint: disable=R0903 -# pylint: disable=R0911 -# pylint: disable=R0201 - -# Standard library imports -import logging -import os -import os.path as osp -import sys -import unicodedata - -# Third party imports -import qstylizer.style -from qtpy.compat import getsavefilename -from qtpy.QtCore import (QByteArray, QFileInfo, QPoint, QSize, Qt, QTimer, - Signal, Slot) -from qtpy.QtGui import QFont, QTextCursor -from qtpy.QtWidgets import (QAction, QApplication, QFileDialog, QHBoxLayout, - QLabel, QMainWindow, QMessageBox, QMenu, - QSplitter, QVBoxLayout, QWidget, QListWidget, - QListWidgetItem, QSizePolicy, QToolBar) - -# Local imports -from spyder.api.panel import Panel -from spyder.config.base import _, running_under_pytest -from spyder.config.manager import CONF -from spyder.config.utils import (get_edit_filetypes, get_edit_filters, - get_filter, is_kde_desktop, is_anaconda) -from spyder.plugins.editor.utils.autosave import AutosaveForStack -from spyder.plugins.editor.utils.editor import get_file_language -from spyder.plugins.editor.utils.switcher import EditorSwitcherManager -from spyder.plugins.editor.widgets import codeeditor -from spyder.plugins.editor.widgets.editorstack_helpers import ( - ThreadManager, FileInfo, StackHistory) -from spyder.plugins.editor.widgets.status import (CursorPositionStatus, - EncodingStatus, EOLStatus, - ReadWriteStatus, VCSStatus) -from spyder.plugins.explorer.widgets.explorer import ( - show_in_external_file_explorer) -from spyder.plugins.explorer.widgets.utils import fixpath -from spyder.plugins.outlineexplorer.main_widget import OutlineExplorerWidget -from spyder.plugins.outlineexplorer.editor import OutlineExplorerProxyEditor -from spyder.plugins.outlineexplorer.api import cell_name -from spyder.py3compat import qbytearray_to_str, to_text_string -from spyder.utils import encoding, sourcecode, syntaxhighlighters -from spyder.utils.icon_manager import ima -from spyder.utils.palette import QStylePalette -from spyder.utils.qthelpers import (add_actions, create_action, - create_toolbutton, MENU_SEPARATOR, - mimedata2url, set_menu_icons, - create_waitspinner) -from spyder.utils.stylesheet import ( - APP_STYLESHEET, APP_TOOLBAR_STYLESHEET, PANES_TABBAR_STYLESHEET) -from spyder.widgets.findreplace import FindReplace -from spyder.widgets.tabs import BaseTabs - - -logger = logging.getLogger(__name__) - - -class TabSwitcherWidget(QListWidget): - """Show tabs in mru order and change between them.""" - - def __init__(self, parent, stack_history, tabs): - QListWidget.__init__(self, parent) - self.setWindowFlags(Qt.FramelessWindowHint | Qt.Dialog) - - self.editor = parent - self.stack_history = stack_history - self.tabs = tabs - - self.setSelectionMode(QListWidget.SingleSelection) - self.itemActivated.connect(self.item_selected) - - self.id_list = [] - self.load_data() - size = CONF.get('main', 'completion/size') - self.resize(*size) - self.set_dialog_position() - self.setCurrentRow(0) - - CONF.config_shortcut(lambda: self.select_row(-1), context='Editor', - name='Go to previous file', parent=self) - CONF.config_shortcut(lambda: self.select_row(1), context='Editor', - name='Go to next file', parent=self) - - def load_data(self): - """Fill ListWidget with the tabs texts. - - Add elements in inverse order of stack_history. - """ - for index in reversed(self.stack_history): - text = self.tabs.tabText(index) - text = text.replace('&', '') - item = QListWidgetItem(ima.icon('TextFileIcon'), text) - self.addItem(item) - - def item_selected(self, item=None): - """Change to the selected document and hide this widget.""" - if item is None: - item = self.currentItem() - - # stack history is in inverse order - try: - index = self.stack_history[-(self.currentRow()+1)] - except IndexError: - pass - else: - self.editor.set_stack_index(index) - self.editor.current_changed(index) - self.hide() - - def select_row(self, steps): - """Move selected row a number of steps. - - Iterates in a cyclic behaviour. - """ - row = (self.currentRow() + steps) % self.count() - self.setCurrentRow(row) - - def set_dialog_position(self): - """Positions the tab switcher in the top-center of the editor.""" - left = int(self.editor.geometry().width()/2 - self.width()/2) - top = int(self.editor.tabs.tabBar().geometry().height() + - self.editor.fname_label.geometry().height()) - - self.move(self.editor.mapToGlobal(QPoint(left, top))) - - def keyReleaseEvent(self, event): - """Reimplement Qt method. - - Handle "most recent used" tab behavior, - When ctrl is released and tab_switcher is visible, tab will be changed. - """ - if self.isVisible(): - qsc = CONF.get_shortcut(context='Editor', name='Go to next file') - - for key in qsc.split('+'): - key = key.lower() - if ((key == 'ctrl' and event.key() == Qt.Key_Control) or - (key == 'alt' and event.key() == Qt.Key_Alt)): - self.item_selected() - event.accept() - - def keyPressEvent(self, event): - """Reimplement Qt method to allow cyclic behavior.""" - if event.key() == Qt.Key_Down: - self.select_row(1) - elif event.key() == Qt.Key_Up: - self.select_row(-1) - - def focusOutEvent(self, event): - """Reimplement Qt method to close the widget when loosing focus.""" - event.ignore() - if sys.platform == "darwin": - if event.reason() != Qt.ActiveWindowFocusReason: - self.close() - else: - self.close() - - -class EditorStack(QWidget): - reset_statusbar = Signal() - readonly_changed = Signal(bool) - encoding_changed = Signal(str) - sig_editor_cursor_position_changed = Signal(int, int) - sig_refresh_eol_chars = Signal(str) - sig_refresh_formatting = Signal(bool) - starting_long_process = Signal(str) - ending_long_process = Signal(str) - redirect_stdio = Signal(bool) - exec_in_extconsole = Signal(str, bool) - run_cell_in_ipyclient = Signal(str, object, str, bool, bool) - debug_cell_in_ipyclient = Signal(str, object, str, bool, bool) - update_plugin_title = Signal() - editor_focus_changed = Signal() - zoom_in = Signal() - zoom_out = Signal() - zoom_reset = Signal() - sig_open_file = Signal(dict) - sig_close_file = Signal(str, str) - file_saved = Signal(str, str, str) - file_renamed_in_data = Signal(str, str, str) - opened_files_list_changed = Signal() - active_languages_stats = Signal(set) - todo_results_changed = Signal() - update_code_analysis_actions = Signal() - refresh_file_dependent_actions = Signal() - refresh_save_all_action = Signal() - sig_breakpoints_saved = Signal() - text_changed_at = Signal(str, int) - current_file_changed = Signal(str, int, int, int) - plugin_load = Signal((str,), ()) - edit_goto = Signal(str, int, str) - sig_split_vertically = Signal() - sig_split_horizontally = Signal() - sig_new_file = Signal((str,), ()) - sig_save_as = Signal() - sig_prev_edit_pos = Signal() - sig_prev_cursor = Signal() - sig_next_cursor = Signal() - sig_prev_warning = Signal() - sig_next_warning = Signal() - sig_go_to_definition = Signal(str, int, int) - sig_perform_completion_request = Signal(str, str, dict) - sig_option_changed = Signal(str, object) # config option needs changing - sig_save_bookmark = Signal(int) - sig_load_bookmark = Signal(int) - sig_save_bookmarks = Signal(str, str) - - sig_help_requested = Signal(dict) - """ - This signal is emitted to request help on a given object `name`. - - Parameters - ---------- - help_data: dict - Dictionary required by the Help pane to render a docstring. - - Examples - -------- - >>> help_data = { - 'obj_text': str, - 'name': str, - 'argspec': str, - 'note': str, - 'docstring': str, - 'force_refresh': bool, - 'path': str, - } - - See Also - -------- - :py:meth:spyder.plugins.editor.widgets.editor.EditorStack.send_to_help - """ - - def __init__(self, parent, actions): - QWidget.__init__(self, parent) - - self.setAttribute(Qt.WA_DeleteOnClose) - - self.threadmanager = ThreadManager(self) - self.new_window = False - self.horsplit_action = None - self.versplit_action = None - self.close_action = None - self.__get_split_actions() - - layout = QVBoxLayout() - layout.setContentsMargins(0, 0, 0, 0) - self.setLayout(layout) - - self.menu = None - self.switcher_dlg = None - self.switcher_manager = None - self.tabs = None - self.tabs_switcher = None - - self.stack_history = StackHistory(self) - - # External panels - self.external_panels = [] - - self.setup_editorstack(parent, layout) - - self.find_widget = None - - self.data = [] - - switcher_action = create_action( - self, - _("File switcher..."), - icon=ima.icon('filelist'), - triggered=self.open_switcher_dlg) - symbolfinder_action = create_action( - self, - _("Find symbols in file..."), - icon=ima.icon('symbol_find'), - triggered=self.open_symbolfinder_dlg) - copy_to_cb_action = create_action(self, _("Copy path to clipboard"), - icon=ima.icon('editcopy'), - triggered=lambda: - QApplication.clipboard().setText(self.get_current_filename())) - close_right = create_action(self, _("Close all to the right"), - triggered=self.close_all_right) - close_all_but_this = create_action(self, _("Close all but this"), - triggered=self.close_all_but_this) - - sort_tabs = create_action(self, _("Sort tabs alphabetically"), - triggered=self.sort_file_tabs_alphabetically) - - if sys.platform == 'darwin': - text = _("Show in Finder") - else: - text = _("Show in external file explorer") - external_fileexp_action = create_action( - self, text, - triggered=self.show_in_external_file_explorer, - shortcut=CONF.get_shortcut(context="Editor", - name="show in external file explorer"), - context=Qt.WidgetShortcut) - - self.menu_actions = actions + [external_fileexp_action, - None, switcher_action, - symbolfinder_action, - copy_to_cb_action, None, close_right, - close_all_but_this, sort_tabs] - self.outlineexplorer = None - self.is_closable = False - self.new_action = None - self.open_action = None - self.save_action = None - self.revert_action = None - self.tempfile_path = None - self.title = _("Editor") - self.todolist_enabled = True - self.is_analysis_done = False - self.linenumbers_enabled = True - self.blanks_enabled = False - self.scrollpastend_enabled = False - self.edgeline_enabled = True - self.edgeline_columns = (79,) - self.close_parentheses_enabled = True - self.close_quotes_enabled = True - self.add_colons_enabled = True - self.auto_unindent_enabled = True - self.indent_chars = " "*4 - self.tab_stop_width_spaces = 4 - self.show_class_func_dropdown = False - self.help_enabled = False - self.default_font = None - self.wrap_enabled = False - self.tabmode_enabled = False - self.stripmode_enabled = False - self.intelligent_backspace_enabled = True - self.automatic_completions_enabled = True - self.automatic_completion_chars = 3 - self.automatic_completion_ms = 300 - self.completions_hint_enabled = True - self.completions_hint_after_ms = 500 - self.hover_hints_enabled = True - self.format_on_save = False - self.code_snippets_enabled = True - self.code_folding_enabled = True - self.underline_errors_enabled = False - self.highlight_current_line_enabled = False - self.highlight_current_cell_enabled = False - self.occurrence_highlighting_enabled = True - self.occurrence_highlighting_timeout = 1500 - self.checkeolchars_enabled = True - self.always_remove_trailing_spaces = False - self.add_newline = False - self.remove_trailing_newlines = False - self.convert_eol_on_save = False - self.convert_eol_on_save_to = 'LF' - self.focus_to_editor = True - self.run_cell_copy = False - self.create_new_file_if_empty = True - self.indent_guides = False - ccs = 'spyder/dark' - if ccs not in syntaxhighlighters.COLOR_SCHEME_NAMES: - ccs = syntaxhighlighters.COLOR_SCHEME_NAMES[0] - self.color_scheme = ccs - self.__file_status_flag = False - - # Real-time code analysis - self.analysis_timer = QTimer(self) - self.analysis_timer.setSingleShot(True) - self.analysis_timer.setInterval(1000) - self.analysis_timer.timeout.connect(self.analyze_script) - - # Update filename label - self.editor_focus_changed.connect(self.update_fname_label) - - # Accepting drops - self.setAcceptDrops(True) - - # Local shortcuts - self.shortcuts = self.create_shortcuts() - - # For opening last closed tabs - self.last_closed_files = [] - - # Reference to save msgbox and avoid memory to be freed. - self.msgbox = None - - # File types and filters used by the Save As dialog - self.edit_filetypes = None - self.edit_filters = None - - # For testing - self.save_dialog_on_tests = not running_under_pytest() - - # Autusave component - self.autosave = AutosaveForStack(self) - - self.last_cell_call = None - - @Slot() - def show_in_external_file_explorer(self, fnames=None): - """Show file in external file explorer""" - if fnames is None or isinstance(fnames, bool): - fnames = self.get_current_filename() - try: - show_in_external_file_explorer(fnames) - except FileNotFoundError as error: - file = str(error).split("'")[1] - if "xdg-open" in file: - msg_title = _("Warning") - msg = _("Spyder can't show this file in the external file " - "explorer because the xdg-utils package is " - "not available on your system.") - QMessageBox.information(self, msg_title, msg, - QMessageBox.Ok) - - def create_shortcuts(self): - """Create local shortcuts""" - # --- Configurable shortcuts - inspect = CONF.config_shortcut( - self.inspect_current_object, - context='Editor', - name='Inspect current object', - parent=self) - - set_breakpoint = CONF.config_shortcut( - self.set_or_clear_breakpoint, - context='Editor', - name='Breakpoint', - parent=self) - - set_cond_breakpoint = CONF.config_shortcut( - self.set_or_edit_conditional_breakpoint, - context='Editor', - name='Conditional breakpoint', - parent=self) - - gotoline = CONF.config_shortcut( - self.go_to_line, - context='Editor', - name='Go to line', - parent=self) - - tab = CONF.config_shortcut( - lambda: self.tab_navigation_mru(forward=False), - context='Editor', - name='Go to previous file', - parent=self) - - tabshift = CONF.config_shortcut( - self.tab_navigation_mru, - context='Editor', - name='Go to next file', - parent=self) - - prevtab = CONF.config_shortcut( - lambda: self.tabs.tab_navigate(-1), - context='Editor', - name='Cycle to previous file', - parent=self) - - nexttab = CONF.config_shortcut( - lambda: self.tabs.tab_navigate(1), - context='Editor', - name='Cycle to next file', - parent=self) - - run_selection = CONF.config_shortcut( - self.run_selection, - context='Editor', - name='Run selection', - parent=self) - - run_to_line = CONF.config_shortcut( - self.run_to_line, - context='Editor', - name='Run to line', - parent=self) - - run_from_line = CONF.config_shortcut( - self.run_from_line, - context='Editor', - name='Run from line', - parent=self) - - new_file = CONF.config_shortcut( - lambda: self.sig_new_file[()].emit(), - context='Editor', - name='New file', - parent=self) - - open_file = CONF.config_shortcut( - lambda: self.plugin_load[()].emit(), - context='Editor', - name='Open file', - parent=self) - - save_file = CONF.config_shortcut( - self.save, - context='Editor', - name='Save file', - parent=self) - - save_all = CONF.config_shortcut( - self.save_all, - context='Editor', - name='Save all', - parent=self) - - save_as = CONF.config_shortcut( - lambda: self.sig_save_as.emit(), - context='Editor', - name='Save As', - parent=self) - - close_all = CONF.config_shortcut( - self.close_all_files, - context='Editor', - name='Close all', - parent=self) - - prev_edit_pos = CONF.config_shortcut( - lambda: self.sig_prev_edit_pos.emit(), - context="Editor", - name="Last edit location", - parent=self) - - prev_cursor = CONF.config_shortcut( - lambda: self.sig_prev_cursor.emit(), - context="Editor", - name="Previous cursor position", - parent=self) - - next_cursor = CONF.config_shortcut( - lambda: self.sig_next_cursor.emit(), - context="Editor", - name="Next cursor position", - parent=self) - - zoom_in_1 = CONF.config_shortcut( - lambda: self.zoom_in.emit(), - context="Editor", - name="zoom in 1", - parent=self) - - zoom_in_2 = CONF.config_shortcut( - lambda: self.zoom_in.emit(), - context="Editor", - name="zoom in 2", - parent=self) - - zoom_out = CONF.config_shortcut( - lambda: self.zoom_out.emit(), - context="Editor", - name="zoom out", - parent=self) - - zoom_reset = CONF.config_shortcut( - lambda: self.zoom_reset.emit(), - context="Editor", - name="zoom reset", - parent=self) - - close_file_1 = CONF.config_shortcut( - self.close_file, - context="Editor", - name="close file 1", - parent=self) - - close_file_2 = CONF.config_shortcut( - self.close_file, - context="Editor", - name="close file 2", - parent=self) - - run_cell = CONF.config_shortcut( - self.run_cell, - context="Editor", - name="run cell", - parent=self) - - debug_cell = CONF.config_shortcut( - self.debug_cell, - context="Editor", - name="debug cell", - parent=self) - - run_cell_and_advance = CONF.config_shortcut( - self.run_cell_and_advance, - context="Editor", - name="run cell and advance", - parent=self) - - go_to_next_cell = CONF.config_shortcut( - self.advance_cell, - context="Editor", - name="go to next cell", - parent=self) - - go_to_previous_cell = CONF.config_shortcut( - lambda: self.advance_cell(reverse=True), - context="Editor", - name="go to previous cell", - parent=self) - - re_run_last_cell = CONF.config_shortcut( - self.re_run_last_cell, - context="Editor", - name="re-run last cell", - parent=self) - - prev_warning = CONF.config_shortcut( - lambda: self.sig_prev_warning.emit(), - context="Editor", - name="Previous warning", - parent=self) - - next_warning = CONF.config_shortcut( - lambda: self.sig_next_warning.emit(), - context="Editor", - name="Next warning", - parent=self) - - split_vertically = CONF.config_shortcut( - lambda: self.sig_split_vertically.emit(), - context="Editor", - name="split vertically", - parent=self) - - split_horizontally = CONF.config_shortcut( - lambda: self.sig_split_horizontally.emit(), - context="Editor", - name="split horizontally", - parent=self) - - close_split = CONF.config_shortcut( - self.close_split, - context="Editor", - name="close split panel", - parent=self) - - external_fileexp = CONF.config_shortcut( - self.show_in_external_file_explorer, - context="Editor", - name="show in external file explorer", - parent=self) - - # Return configurable ones - return [inspect, set_breakpoint, set_cond_breakpoint, gotoline, tab, - tabshift, run_selection, run_to_line, run_from_line, new_file, - open_file, save_file, save_all, save_as, close_all, - prev_edit_pos, prev_cursor, next_cursor, zoom_in_1, zoom_in_2, - zoom_out, zoom_reset, close_file_1, close_file_2, run_cell, - debug_cell, run_cell_and_advance, - go_to_next_cell, go_to_previous_cell, re_run_last_cell, - prev_warning, next_warning, split_vertically, - split_horizontally, close_split, - prevtab, nexttab, external_fileexp] - - def get_shortcut_data(self): - """ - Returns shortcut data, a list of tuples (shortcut, text, default) - shortcut (QShortcut or QAction instance) - text (string): action/shortcut description - default (string): default key sequence - """ - return [sc.data for sc in self.shortcuts] - - def setup_editorstack(self, parent, layout): - """Setup editorstack's layout""" - layout.setSpacing(0) - - # Create filename label, spinner and the toolbar that contains them - self.create_top_widgets() - - # Add top toolbar - layout.addWidget(self.top_toolbar) - - # Tabbar - menu_btn = create_toolbutton(self, icon=ima.icon('tooloptions'), - tip=_('Options')) - menu_btn.setStyleSheet(str(PANES_TABBAR_STYLESHEET)) - self.menu = QMenu(self) - menu_btn.setMenu(self.menu) - menu_btn.setPopupMode(menu_btn.InstantPopup) - self.menu.aboutToShow.connect(self.__setup_menu) - - corner_widgets = {Qt.TopRightCorner: [menu_btn]} - self.tabs = BaseTabs(self, menu=self.menu, menu_use_tooltips=True, - corner_widgets=corner_widgets) - self.tabs.set_close_function(self.close_file) - self.tabs.tabBar().tabMoved.connect(self.move_editorstack_data) - self.tabs.setMovable(True) - - self.stack_history.refresh() - - if hasattr(self.tabs, 'setDocumentMode') \ - and not sys.platform == 'darwin': - # Don't set document mode to true on OSX because it generates - # a crash when the editor is detached from the main window - # Fixes spyder-ide/spyder#561. - self.tabs.setDocumentMode(True) - self.tabs.currentChanged.connect(self.current_changed) - - tab_container = QWidget() - tab_container.setObjectName('tab-container') - tab_layout = QHBoxLayout(tab_container) - tab_layout.setContentsMargins(0, 0, 0, 0) - tab_layout.addWidget(self.tabs) - layout.addWidget(tab_container) - - # Show/hide icons in plugin menus for Mac - if sys.platform == 'darwin': - self.menu.aboutToHide.connect( - lambda menu=self.menu: - set_menu_icons(menu, False)) - - def create_top_widgets(self): - # Filename label - self.fname_label = QLabel() - - # Spacer - spacer = QWidget() - spacer.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Preferred) - - # Spinner - self.spinner = create_waitspinner(size=16, parent=self.fname_label) - - # Add widgets to toolbar - self.top_toolbar = QToolBar(self) - self.top_toolbar.addWidget(self.fname_label) - self.top_toolbar.addWidget(spacer) - self.top_toolbar.addWidget(self.spinner) - - # Set toolbar style - css = qstylizer.style.StyleSheet() - css.QToolBar.setValues( - margin='0px', - padding='4px', - borderBottom=f'1px solid {QStylePalette.COLOR_BACKGROUND_4}' - ) - self.top_toolbar.setStyleSheet(css.toString()) - - def hide_tooltip(self): - """Hide any open tooltips.""" - for finfo in self.data: - finfo.editor.hide_tooltip() - - @Slot() - def update_fname_label(self): - """Update file name label.""" - filename = to_text_string(self.get_current_filename()) - if len(filename) > 100: - shorten_filename = u'...' + filename[-100:] - else: - shorten_filename = filename - self.fname_label.setText(shorten_filename) - - def add_corner_widgets_to_tabbar(self, widgets): - self.tabs.add_corner_widgets(widgets) - - @Slot() - def close_split(self): - """Closes the editorstack if it is not the last one opened.""" - if self.is_closable: - self.close() - - def closeEvent(self, event): - """Overrides QWidget closeEvent().""" - self.threadmanager.close_all_threads() - self.analysis_timer.timeout.disconnect(self.analyze_script) - - # Remove editor references from the outline explorer settings - if self.outlineexplorer is not None: - for finfo in self.data: - self.outlineexplorer.remove_editor(finfo.editor.oe_proxy) - - for finfo in self.data: - if not finfo.editor.is_cloned: - finfo.editor.notify_close() - QWidget.closeEvent(self, event) - - def clone_editor_from(self, other_finfo, set_current): - fname = other_finfo.filename - enc = other_finfo.encoding - new = other_finfo.newly_created - finfo = self.create_new_editor(fname, enc, "", - set_current=set_current, new=new, - cloned_from=other_finfo.editor) - finfo.set_todo_results(other_finfo.todo_results) - return finfo.editor - - def clone_from(self, other): - """Clone EditorStack from other instance""" - for other_finfo in other.data: - self.clone_editor_from(other_finfo, set_current=True) - self.set_stack_index(other.get_stack_index()) - - @Slot() - @Slot(str) - def open_switcher_dlg(self, initial_text=''): - """Open file list management dialog box""" - if not self.tabs.count(): - return - if self.switcher_dlg is not None and self.switcher_dlg.isVisible(): - self.switcher_dlg.hide() - self.switcher_dlg.clear() - return - if self.switcher_dlg is None: - from spyder.widgets.switcher import Switcher - self.switcher_dlg = Switcher(self) - self.switcher_manager = EditorSwitcherManager( - self.get_plugin(), - self.switcher_dlg, - lambda: self.get_current_editor(), - lambda: self, - section=self.get_plugin_title()) - - if isinstance(initial_text, bool): - initial_text = '' - - self.switcher_dlg.set_search_text(initial_text) - self.switcher_dlg.setup() - self.switcher_dlg.show() - # Note: the +1 pixel on the top makes it look better - delta_top = (self.tabs.tabBar().geometry().height() + - self.fname_label.geometry().height() + 1) - self.switcher_dlg.set_position(delta_top) - - @Slot() - def open_symbolfinder_dlg(self): - self.open_switcher_dlg(initial_text='@') - - def get_plugin(self): - """Get the plugin of the parent widget.""" - # Needed for the editor stack to use its own switcher instance. - # See spyder-ide/spyder#10684. - return self.parent().plugin - - def get_plugin_title(self): - """Get the plugin title of the parent widget.""" - # Needed for the editor stack to use its own switcher instance. - # See spyder-ide/spyder#9469. - return self.get_plugin().get_plugin_title() - - def go_to_line(self, line=None): - """Go to line dialog""" - if line is not None: - # When this method is called from the flileswitcher, a line - # number is specified, so there is no need for the dialog. - self.get_current_editor().go_to_line(line) - else: - if self.data: - self.get_current_editor().exec_gotolinedialog() - - def set_or_clear_breakpoint(self): - """Set/clear breakpoint""" - if self.data: - editor = self.get_current_editor() - editor.debugger.toogle_breakpoint() - - def set_or_edit_conditional_breakpoint(self): - """Set conditional breakpoint""" - if self.data: - editor = self.get_current_editor() - editor.debugger.toogle_breakpoint(edit_condition=True) - - def set_bookmark(self, slot_num): - """Bookmark current position to given slot.""" - if self.data: - editor = self.get_current_editor() - editor.add_bookmark(slot_num) - - def inspect_current_object(self, pos=None): - """Inspect current object in the Help plugin""" - editor = self.get_current_editor() - editor.sig_display_object_info.connect(self.display_help) - cursor = None - offset = editor.get_position('cursor') - if pos: - cursor = editor.get_last_hover_cursor() - if cursor: - offset = cursor.position() - else: - return - - line, col = editor.get_cursor_line_column(cursor) - editor.request_hover(line, col, offset, - show_hint=False, clicked=bool(pos)) - - @Slot(str, bool) - def display_help(self, help_text, clicked): - editor = self.get_current_editor() - if clicked: - name = editor.get_last_hover_word() - else: - name = editor.get_current_word(help_req=True) - - try: - editor.sig_display_object_info.disconnect(self.display_help) - except TypeError: - # Needed to prevent an error after some time in idle. - # See spyder-ide/spyder#11228 - pass - - self.send_to_help(name, help_text, force=True) - - # ------ Editor Widget Settings - def set_closable(self, state): - """Parent widget must handle the closable state""" - self.is_closable = state - - def set_io_actions(self, new_action, open_action, - save_action, revert_action): - self.new_action = new_action - self.open_action = open_action - self.save_action = save_action - self.revert_action = revert_action - - def set_find_widget(self, find_widget): - self.find_widget = find_widget - - def set_outlineexplorer(self, outlineexplorer): - self.outlineexplorer = outlineexplorer - - def add_outlineexplorer_button(self, editor_plugin): - oe_btn = create_toolbutton(editor_plugin) - oe_btn.setDefaultAction(self.outlineexplorer.visibility_action) - self.add_corner_widgets_to_tabbar([5, oe_btn]) - - def set_tempfile_path(self, path): - self.tempfile_path = path - - def set_title(self, text): - self.title = text - - def set_classfunc_dropdown_visible(self, state): - self.show_class_func_dropdown = state - if self.data: - for finfo in self.data: - if finfo.editor.is_python_like(): - finfo.editor.classfuncdropdown.setVisible(state) - - def __update_editor_margins(self, editor): - editor.linenumberarea.setup_margins( - linenumbers=self.linenumbers_enabled, markers=self.has_markers()) - - def has_markers(self): - """Return True if this editorstack has a marker margin for TODOs or - code analysis""" - return self.todolist_enabled - - def set_todolist_enabled(self, state, current_finfo=None): - # CONF.get(self.CONF_SECTION, 'todo_list') - self.todolist_enabled = state - if self.data: - for finfo in self.data: - self.__update_editor_margins(finfo.editor) - finfo.cleanup_todo_results() - if state and current_finfo is not None: - if current_finfo is not finfo: - finfo.run_todo_finder() - - def set_linenumbers_enabled(self, state, current_finfo=None): - # CONF.get(self.CONF_SECTION, 'line_numbers') - self.linenumbers_enabled = state - if self.data: - for finfo in self.data: - self.__update_editor_margins(finfo.editor) - - def set_blanks_enabled(self, state): - self.blanks_enabled = state - if self.data: - for finfo in self.data: - finfo.editor.set_blanks_enabled(state) - - def set_scrollpastend_enabled(self, state): - self.scrollpastend_enabled = state - if self.data: - for finfo in self.data: - finfo.editor.set_scrollpastend_enabled(state) - - def set_edgeline_enabled(self, state): - # CONF.get(self.CONF_SECTION, 'edge_line') - self.edgeline_enabled = state - if self.data: - for finfo in self.data: - finfo.editor.edge_line.set_enabled(state) - - def set_edgeline_columns(self, columns): - # CONF.get(self.CONF_SECTION, 'edge_line_column') - self.edgeline_columns = columns - if self.data: - for finfo in self.data: - finfo.editor.edge_line.set_columns(columns) - - def set_indent_guides(self, state): - self.indent_guides = state - if self.data: - for finfo in self.data: - finfo.editor.toggle_identation_guides(state) - - def set_close_parentheses_enabled(self, state): - # CONF.get(self.CONF_SECTION, 'close_parentheses') - self.close_parentheses_enabled = state - if self.data: - for finfo in self.data: - finfo.editor.set_close_parentheses_enabled(state) - - def set_close_quotes_enabled(self, state): - # CONF.get(self.CONF_SECTION, 'close_quotes') - self.close_quotes_enabled = state - if self.data: - for finfo in self.data: - finfo.editor.set_close_quotes_enabled(state) - - def set_add_colons_enabled(self, state): - # CONF.get(self.CONF_SECTION, 'add_colons') - self.add_colons_enabled = state - if self.data: - for finfo in self.data: - finfo.editor.set_add_colons_enabled(state) - - def set_auto_unindent_enabled(self, state): - # CONF.get(self.CONF_SECTION, 'auto_unindent') - self.auto_unindent_enabled = state - if self.data: - for finfo in self.data: - finfo.editor.set_auto_unindent_enabled(state) - - def set_indent_chars(self, indent_chars): - # CONF.get(self.CONF_SECTION, 'indent_chars') - indent_chars = indent_chars[1:-1] # removing the leading/ending '*' - self.indent_chars = indent_chars - if self.data: - for finfo in self.data: - finfo.editor.set_indent_chars(indent_chars) - - def set_tab_stop_width_spaces(self, tab_stop_width_spaces): - # CONF.get(self.CONF_SECTION, 'tab_stop_width') - self.tab_stop_width_spaces = tab_stop_width_spaces - if self.data: - for finfo in self.data: - finfo.editor.tab_stop_width_spaces = tab_stop_width_spaces - finfo.editor.update_tab_stop_width_spaces() - - def set_help_enabled(self, state): - self.help_enabled = state - - def set_default_font(self, font, color_scheme=None): - self.default_font = font - if color_scheme is not None: - self.color_scheme = color_scheme - if self.data: - for finfo in self.data: - finfo.editor.set_font(font, color_scheme) - - def set_color_scheme(self, color_scheme): - self.color_scheme = color_scheme - if self.data: - for finfo in self.data: - finfo.editor.set_color_scheme(color_scheme) - - def set_wrap_enabled(self, state): - # CONF.get(self.CONF_SECTION, 'wrap') - self.wrap_enabled = state - if self.data: - for finfo in self.data: - finfo.editor.toggle_wrap_mode(state) - - def set_tabmode_enabled(self, state): - # CONF.get(self.CONF_SECTION, 'tab_always_indent') - self.tabmode_enabled = state - if self.data: - for finfo in self.data: - finfo.editor.set_tab_mode(state) - - def set_stripmode_enabled(self, state): - # CONF.get(self.CONF_SECTION, 'strip_trailing_spaces_on_modify') - self.stripmode_enabled = state - if self.data: - for finfo in self.data: - finfo.editor.set_strip_mode(state) - - def set_intelligent_backspace_enabled(self, state): - # CONF.get(self.CONF_SECTION, 'intelligent_backspace') - self.intelligent_backspace_enabled = state - if self.data: - for finfo in self.data: - finfo.editor.toggle_intelligent_backspace(state) - - def set_code_snippets_enabled(self, state): - self.code_snippets_enabled = state - if self.data: - for finfo in self.data: - finfo.editor.toggle_code_snippets(state) - - def set_code_folding_enabled(self, state): - self.code_folding_enabled = state - if self.data: - for finfo in self.data: - finfo.editor.toggle_code_folding(state) - - def set_automatic_completions_enabled(self, state): - self.automatic_completions_enabled = state - if self.data: - for finfo in self.data: - finfo.editor.toggle_automatic_completions(state) - - def set_automatic_completions_after_chars(self, chars): - self.automatic_completion_chars = chars - if self.data: - for finfo in self.data: - finfo.editor.set_automatic_completions_after_chars(chars) - - def set_automatic_completions_after_ms(self, ms): - self.automatic_completion_ms = ms - if self.data: - for finfo in self.data: - finfo.editor.set_automatic_completions_after_ms(ms) - - def set_completions_hint_enabled(self, state): - self.completions_hint_enabled = state - if self.data: - for finfo in self.data: - finfo.editor.toggle_completions_hint(state) - - def set_completions_hint_after_ms(self, ms): - self.completions_hint_after_ms = ms - if self.data: - for finfo in self.data: - finfo.editor.set_completions_hint_after_ms(ms) - - def set_hover_hints_enabled(self, state): - self.hover_hints_enabled = state - if self.data: - for finfo in self.data: - finfo.editor.toggle_hover_hints(state) - - def set_format_on_save(self, state): - self.format_on_save = state - if self.data: - for finfo in self.data: - finfo.editor.toggle_format_on_save(state) - - def set_occurrence_highlighting_enabled(self, state): - # CONF.get(self.CONF_SECTION, 'occurrence_highlighting') - self.occurrence_highlighting_enabled = state - if self.data: - for finfo in self.data: - finfo.editor.set_occurrence_highlighting(state) - - def set_occurrence_highlighting_timeout(self, timeout): - # CONF.get(self.CONF_SECTION, 'occurrence_highlighting/timeout') - self.occurrence_highlighting_timeout = timeout - if self.data: - for finfo in self.data: - finfo.editor.set_occurrence_timeout(timeout) - - def set_underline_errors_enabled(self, state): - self.underline_errors_enabled = state - if self.data: - for finfo in self.data: - finfo.editor.set_underline_errors_enabled(state) - - def set_highlight_current_line_enabled(self, state): - self.highlight_current_line_enabled = state - if self.data: - for finfo in self.data: - finfo.editor.set_highlight_current_line(state) - - def set_highlight_current_cell_enabled(self, state): - self.highlight_current_cell_enabled = state - if self.data: - for finfo in self.data: - finfo.editor.set_highlight_current_cell(state) - - def set_checkeolchars_enabled(self, state): - # CONF.get(self.CONF_SECTION, 'check_eol_chars') - self.checkeolchars_enabled = state - - def set_always_remove_trailing_spaces(self, state): - # CONF.get(self.CONF_SECTION, 'always_remove_trailing_spaces') - self.always_remove_trailing_spaces = state - if self.data: - for finfo in self.data: - finfo.editor.set_remove_trailing_spaces(state) - - def set_add_newline(self, state): - self.add_newline = state - if self.data: - for finfo in self.data: - finfo.editor.set_add_newline(state) - - def set_remove_trailing_newlines(self, state): - self.remove_trailing_newlines = state - if self.data: - for finfo in self.data: - finfo.editor.set_remove_trailing_newlines(state) - - def set_convert_eol_on_save(self, state): - """If `state` is `True`, saving files will convert line endings.""" - # CONF.get(self.CONF_SECTION, 'convert_eol_on_save') - self.convert_eol_on_save = state - - def set_convert_eol_on_save_to(self, state): - """`state` can be one of ('LF', 'CRLF', 'CR')""" - # CONF.get(self.CONF_SECTION, 'convert_eol_on_save_to') - self.convert_eol_on_save_to = state - - def set_focus_to_editor(self, state): - self.focus_to_editor = state - - def set_run_cell_copy(self, state): - """If `state` is ``True``, code cells will be copied to the console.""" - self.run_cell_copy = state - - def set_current_project_path(self, root_path=None): - """ - Set the current active project root path. - - Parameters - ---------- - root_path: str or None, optional - Path to current project root path. Default is None. - """ - for finfo in self.data: - finfo.editor.set_current_project_path(root_path) - - # ------ Stacked widget management - def get_stack_index(self): - return self.tabs.currentIndex() - - def get_current_finfo(self): - if self.data: - return self.data[self.get_stack_index()] - - def get_current_editor(self): - return self.tabs.currentWidget() - - def get_stack_count(self): - return self.tabs.count() - - def set_stack_index(self, index, instance=None): - if instance == self or instance == None: - self.tabs.setCurrentIndex(index) - - def set_tabbar_visible(self, state): - self.tabs.tabBar().setVisible(state) - - def remove_from_data(self, index): - self.tabs.blockSignals(True) - self.tabs.removeTab(index) - self.data.pop(index) - self.tabs.blockSignals(False) - self.update_actions() - - def __modified_readonly_title(self, title, is_modified, is_readonly): - if is_modified is not None and is_modified: - title += "*" - if is_readonly is not None and is_readonly: - title = "(%s)" % title - return title - - def get_tab_text(self, index, is_modified=None, is_readonly=None): - """Return tab title.""" - files_path_list = [finfo.filename for finfo in self.data] - fname = self.data[index].filename - fname = sourcecode.disambiguate_fname(files_path_list, fname) - return self.__modified_readonly_title(fname, - is_modified, is_readonly) - - def get_tab_tip(self, filename, is_modified=None, is_readonly=None): - """Return tab menu title""" - text = u"%s — %s" - text = self.__modified_readonly_title(text, - is_modified, is_readonly) - if self.tempfile_path is not None\ - and filename == encoding.to_unicode_from_fs(self.tempfile_path): - temp_file_str = to_text_string(_("Temporary file")) - return text % (temp_file_str, self.tempfile_path) - else: - return text % (osp.basename(filename), osp.dirname(filename)) - - def add_to_data(self, finfo, set_current, add_where='end'): - finfo.editor.oe_proxy = None - index = 0 if add_where == 'start' else len(self.data) - self.data.insert(index, finfo) - index = self.data.index(finfo) - editor = finfo.editor - self.tabs.insertTab(index, editor, self.get_tab_text(index)) - self.set_stack_title(index, False) - if set_current: - self.set_stack_index(index) - self.current_changed(index) - self.update_actions() - - def __repopulate_stack(self): - self.tabs.blockSignals(True) - self.tabs.clear() - for finfo in self.data: - if finfo.newly_created: - is_modified = True - else: - is_modified = None - index = self.data.index(finfo) - tab_text = self.get_tab_text(index, is_modified) - tab_tip = self.get_tab_tip(finfo.filename) - index = self.tabs.addTab(finfo.editor, tab_text) - self.tabs.setTabToolTip(index, tab_tip) - self.tabs.blockSignals(False) - - def rename_in_data(self, original_filename, new_filename): - index = self.has_filename(original_filename) - if index is None: - return - finfo = self.data[index] - - # Send close request to LSP - finfo.editor.notify_close() - - # Set new filename - finfo.filename = new_filename - finfo.editor.filename = new_filename - - # File type has changed! - original_ext = osp.splitext(original_filename)[1] - new_ext = osp.splitext(new_filename)[1] - if original_ext != new_ext: - # Set file language and re-run highlighter - txt = to_text_string(finfo.editor.get_text_with_eol()) - language = get_file_language(new_filename, txt) - finfo.editor.set_language(language, new_filename) - finfo.editor.run_pygments_highlighter() - - # If the user renamed the file to a different language, we - # need to emit sig_open_file to see if we can start a - # language server for it. - options = { - 'language': language, - 'filename': new_filename, - 'codeeditor': finfo.editor - } - self.sig_open_file.emit(options) - - # Update panels - finfo.editor.set_debug_panel( - show_debug_panel=True, language=language) - finfo.editor.cleanup_code_analysis() - finfo.editor.cleanup_folding() - else: - # If there's no language change, we simply need to request a - # document_did_open for the new file. - finfo.editor.document_did_open() - - set_new_index = index == self.get_stack_index() - current_fname = self.get_current_filename() - finfo.editor.filename = new_filename - new_index = self.data.index(finfo) - self.__repopulate_stack() - if set_new_index: - self.set_stack_index(new_index) - else: - # Fixes spyder-ide/spyder#1287. - self.set_current_filename(current_fname) - if self.outlineexplorer is not None: - self.outlineexplorer.file_renamed( - finfo.editor.oe_proxy, finfo.filename) - return new_index - - def set_stack_title(self, index, is_modified): - finfo = self.data[index] - fname = finfo.filename - is_modified = (is_modified or finfo.newly_created) and not finfo.default - is_readonly = finfo.editor.isReadOnly() - tab_text = self.get_tab_text(index, is_modified, is_readonly) - tab_tip = self.get_tab_tip(fname, is_modified, is_readonly) - - # Only update tab text if have changed, otherwise an unwanted scrolling - # will happen when changing tabs. See spyder-ide/spyder#1170. - if tab_text != self.tabs.tabText(index): - self.tabs.setTabText(index, tab_text) - self.tabs.setTabToolTip(index, tab_tip) - - # ------ Context menu - def __setup_menu(self): - """Setup tab context menu before showing it""" - self.menu.clear() - if self.data: - actions = self.menu_actions - else: - actions = (self.new_action, self.open_action) - self.setFocus() # --> Editor.__get_focus_editortabwidget - add_actions(self.menu, list(actions) + self.__get_split_actions()) - self.close_action.setEnabled(self.is_closable) - - if sys.platform == 'darwin': - set_menu_icons(self.menu, True) - - # ------ Hor/Ver splitting - def __get_split_actions(self): - if self.parent() is not None: - plugin = self.parent().plugin - else: - plugin = None - - # New window - if plugin is not None: - self.new_window_action = create_action( - self, _("New window"), - icon=ima.icon('newwindow'), - tip=_("Create a new editor window"), - triggered=plugin.create_new_window) - - # Splitting - self.versplit_action = create_action( - self, - _("Split vertically"), - icon=ima.icon('versplit'), - tip=_("Split vertically this editor window"), - triggered=lambda: self.sig_split_vertically.emit(), - shortcut=CONF.get_shortcut(context='Editor', - name='split vertically'), - context=Qt.WidgetShortcut) - - self.horsplit_action = create_action( - self, - _("Split horizontally"), - icon=ima.icon('horsplit'), - tip=_("Split horizontally this editor window"), - triggered=lambda: self.sig_split_horizontally.emit(), - shortcut=CONF.get_shortcut(context='Editor', - name='split horizontally'), - context=Qt.WidgetShortcut) - - self.close_action = create_action( - self, - _("Close this panel"), - icon=ima.icon('close_panel'), - triggered=self.close_split, - shortcut=CONF.get_shortcut(context='Editor', - name='close split panel'), - context=Qt.WidgetShortcut) - - # Regular actions - actions = [MENU_SEPARATOR, self.versplit_action, - self.horsplit_action, self.close_action] - - if self.new_window: - window = self.window() - close_window_action = create_action( - self, _("Close window"), - icon=ima.icon('close_pane'), - triggered=window.close) - actions += [MENU_SEPARATOR, self.new_window_action, - close_window_action] - elif plugin is not None: - if plugin._undocked_window is not None: - actions += [MENU_SEPARATOR, plugin._dock_action] - else: - actions += [MENU_SEPARATOR, self.new_window_action, - plugin._lock_unlock_action, - plugin._undock_action, - plugin._close_plugin_action] - - return actions - - def reset_orientation(self): - self.horsplit_action.setEnabled(True) - self.versplit_action.setEnabled(True) - - def set_orientation(self, orientation): - self.horsplit_action.setEnabled(orientation == Qt.Horizontal) - self.versplit_action.setEnabled(orientation == Qt.Vertical) - - def update_actions(self): - state = self.get_stack_count() > 0 - self.horsplit_action.setEnabled(state) - self.versplit_action.setEnabled(state) - - # ------ Accessors - def get_current_filename(self): - if self.data: - return self.data[self.get_stack_index()].filename - - def get_current_language(self): - if self.data: - return self.data[self.get_stack_index()].editor.language - - def get_filenames(self): - """ - Return a list with the names of all the files currently opened in - the editorstack. - """ - return [finfo.filename for finfo in self.data] - - def has_filename(self, filename): - """Return the self.data index position for the filename. - - Args: - filename: Name of the file to search for in self.data. - - Returns: - The self.data index for the filename. Returns None - if the filename is not found in self.data. - """ - data_filenames = self.get_filenames() - try: - # Try finding without calling the slow realpath - return data_filenames.index(filename) - except ValueError: - # See note about OSError on set_current_filename - # Fixes spyder-ide/spyder#17685 - try: - filename = fixpath(filename) - except OSError: - return None - - for index, editor_filename in enumerate(data_filenames): - if filename == fixpath(editor_filename): - return index - return None - - def set_current_filename(self, filename, focus=True): - """Set current filename and return the associated editor instance.""" - # FileNotFoundError: This is necessary to catch an error on Windows - # for files in a directory junction pointing to a symlink whose target - # is on a network drive that is unavailable at startup. - # Fixes spyder-ide/spyder#15714 - # OSError: This is necessary to catch an error on Windows when Spyder - # was closed with a file in a shared folder on a different computer on - # the network, and is started again when that folder is not available. - # Fixes spyder-ide/spyder#17685 - try: - index = self.has_filename(filename) - except (FileNotFoundError, OSError): - index = None - - if index is not None: - if focus: - self.set_stack_index(index) - editor = self.data[index].editor - if focus: - editor.setFocus() - else: - self.stack_history.remove_and_append(index) - - return editor - - def is_file_opened(self, filename=None): - """Return if filename is in the editor stack. - - Args: - filename: Name of the file to search for. If filename is None, - then checks if any file is open. - - Returns: - True: If filename is None and a file is open. - False: If filename is None and no files are open. - None: If filename is not None and the file isn't found. - integer: Index of file name in editor stack. - """ - if filename is None: - # Is there any file opened? - return len(self.data) > 0 - else: - return self.has_filename(filename) - - def get_index_from_filename(self, filename): - """ - Return the position index of a file in the tab bar of the editorstack - from its name. - """ - filenames = [d.filename for d in self.data] - return filenames.index(filename) - - @Slot(int, int) - def move_editorstack_data(self, start, end): - """Reorder editorstack.data so it is synchronized with the tab bar when - tabs are moved.""" - if start < 0 or end < 0: - return - else: - steps = abs(end - start) - direction = (end-start) // steps # +1 for right, -1 for left - - data = self.data - self.blockSignals(True) - - for i in range(start, end, direction): - data[i], data[i+direction] = data[i+direction], data[i] - - self.blockSignals(False) - self.refresh() - - # ------ Close file, tabwidget... - def close_file(self, index=None, force=False): - """Close file (index=None -> close current file) - Keep current file index unchanged (if current file - that is being closed)""" - current_index = self.get_stack_index() - count = self.get_stack_count() - - if index is None: - if count > 0: - index = current_index - else: - self.find_widget.set_editor(None) - return - - new_index = None - if count > 1: - if current_index == index: - new_index = self._get_previous_file_index() - else: - new_index = current_index - - can_close_file = self.parent().plugin.can_close_file( - self.data[index].filename) if self.parent() else True - is_ok = (force or self.save_if_changed(cancelable=True, index=index) - and can_close_file) - if is_ok: - finfo = self.data[index] - self.threadmanager.close_threads(finfo) - # Removing editor reference from outline explorer settings: - if self.outlineexplorer is not None: - self.outlineexplorer.remove_editor(finfo.editor.oe_proxy) - - filename = self.data[index].filename - self.remove_from_data(index) - finfo.editor.notify_close() - - # We pass self object ID as a QString, because otherwise it would - # depend on the platform: long for 64bit, int for 32bit. Replacing - # by long all the time is not working on some 32bit platforms. - # See spyder-ide/spyder#1094 and spyder-ide/spyder#1098. - self.sig_close_file.emit(str(id(self)), filename) - - self.opened_files_list_changed.emit() - self.update_code_analysis_actions.emit() - self.refresh_file_dependent_actions.emit() - self.update_plugin_title.emit() - - editor = self.get_current_editor() - if editor: - editor.setFocus() - - if new_index is not None: - if index < new_index: - new_index -= 1 - self.set_stack_index(new_index) - - self.add_last_closed_file(finfo.filename) - - if finfo.filename in self.autosave.file_hashes: - del self.autosave.file_hashes[finfo.filename] - - if self.get_stack_count() == 0 and self.create_new_file_if_empty: - self.sig_new_file[()].emit() - self.update_fname_label() - return False - self.__modify_stack_title() - return is_ok - - def register_completion_capabilities(self, capabilities, language): - """ - Register completion server capabilities across all editors. - - Parameters - ---------- - capabilities: dict - Capabilities supported by a language server. - language: str - Programming language for the language server (it has to be - in small caps). - """ - for index in range(self.get_stack_count()): - editor = self.tabs.widget(index) - if editor.language.lower() == language: - editor.register_completion_capabilities(capabilities) - - def start_completion_services(self, language): - """Notify language server availability to code editors.""" - for index in range(self.get_stack_count()): - editor = self.tabs.widget(index) - if editor.language.lower() == language: - editor.start_completion_services() - - def stop_completion_services(self, language): - """Notify language server unavailability to code editors.""" - for index in range(self.get_stack_count()): - editor = self.tabs.widget(index) - if editor.language.lower() == language: - editor.stop_completion_services() - - def close_all_files(self): - """Close all opened scripts""" - while self.close_file(): - pass - - def close_all_right(self): - """ Close all files opened to the right """ - num = self.get_stack_index() - n = self.get_stack_count() - for __ in range(num, n-1): - self.close_file(num+1) - - def close_all_but_this(self): - """Close all files but the current one""" - self.close_all_right() - for __ in range(0, self.get_stack_count() - 1): - self.close_file(0) - - def sort_file_tabs_alphabetically(self): - """Sort open tabs alphabetically.""" - while self.sorted() is False: - for i in range(0, self.tabs.tabBar().count()): - if(self.tabs.tabBar().tabText(i) > - self.tabs.tabBar().tabText(i + 1)): - self.tabs.tabBar().moveTab(i, i + 1) - - def sorted(self): - """Utility function for sort_file_tabs_alphabetically().""" - for i in range(0, self.tabs.tabBar().count() - 1): - if (self.tabs.tabBar().tabText(i) > - self.tabs.tabBar().tabText(i + 1)): - return False - return True - - def add_last_closed_file(self, fname): - """Add to last closed file list.""" - if fname in self.last_closed_files: - self.last_closed_files.remove(fname) - self.last_closed_files.insert(0, fname) - if len(self.last_closed_files) > 10: - self.last_closed_files.pop(-1) - - def get_last_closed_files(self): - return self.last_closed_files - - def set_last_closed_files(self, fnames): - self.last_closed_files = fnames - - # ------ Save - def save_if_changed(self, cancelable=False, index=None): - """Ask user to save file if modified. - - Args: - cancelable: Show Cancel button. - index: File to check for modification. - - Returns: - False when save() fails or is cancelled. - True when save() is successful, there are no modifications, - or user selects No or NoToAll. - - This function controls the message box prompt for saving - changed files. The actual save is performed in save() for - each index processed. This function also removes autosave files - corresponding to files the user chooses not to save. - """ - if index is None: - indexes = list(range(self.get_stack_count())) - else: - indexes = [index] - buttons = QMessageBox.Yes | QMessageBox.No - if cancelable: - buttons |= QMessageBox.Cancel - unsaved_nb = 0 - for index in indexes: - if self.data[index].editor.document().isModified(): - unsaved_nb += 1 - if not unsaved_nb: - # No file to save - return True - if unsaved_nb > 1: - buttons |= int(QMessageBox.YesToAll | QMessageBox.NoToAll) - yes_all = no_all = False - for index in indexes: - self.set_stack_index(index) - finfo = self.data[index] - if finfo.filename == self.tempfile_path or yes_all: - if not self.save(index): - return False - elif no_all: - self.autosave.remove_autosave_file(finfo) - elif (finfo.editor.document().isModified() and - self.save_dialog_on_tests): - - self.msgbox = QMessageBox( - QMessageBox.Question, - self.title, - _("%s has been modified." - "
Do you want to save changes?" - ) % osp.basename(finfo.filename), - buttons, - parent=self) - - answer = self.msgbox.exec_() - if answer == QMessageBox.Yes: - if not self.save(index): - return False - elif answer == QMessageBox.No: - self.autosave.remove_autosave_file(finfo.filename) - elif answer == QMessageBox.YesToAll: - if not self.save(index): - return False - yes_all = True - elif answer == QMessageBox.NoToAll: - self.autosave.remove_autosave_file(finfo.filename) - no_all = True - elif answer == QMessageBox.Cancel: - return False - return True - - def compute_hash(self, fileinfo): - """Compute hash of contents of editor. - - Args: - fileinfo: FileInfo object associated to editor whose hash needs - to be computed. - - Returns: - int: computed hash. - """ - txt = to_text_string(fileinfo.editor.get_text_with_eol()) - return hash(txt) - - def _write_to_file(self, fileinfo, filename): - """Low-level function for writing text of editor to file. - - Args: - fileinfo: FileInfo object associated to editor to be saved - filename: str with filename to save to - - This is a low-level function that only saves the text to file in the - correct encoding without doing any error handling. - """ - txt = to_text_string(fileinfo.editor.get_text_with_eol()) - fileinfo.encoding = encoding.write(txt, filename, fileinfo.encoding) - - def save(self, index=None, force=False, save_new_files=True): - """Write text of editor to a file. - - Args: - index: self.data index to save. If None, defaults to - currentIndex(). - force: Force save regardless of file state. - - Returns: - True upon successful save or when file doesn't need to be saved. - False if save failed. - - If the text isn't modified and it's not newly created, then the save - is aborted. If the file hasn't been saved before, then save_as() - is invoked. Otherwise, the file is written using the file name - currently in self.data. This function doesn't change the file name. - """ - if index is None: - # Save the currently edited file - if not self.get_stack_count(): - return - index = self.get_stack_index() - - finfo = self.data[index] - if not (finfo.editor.document().isModified() or - finfo.newly_created) and not force: - return True - if not osp.isfile(finfo.filename) and not force: - # File has not been saved yet - if save_new_files: - return self.save_as(index=index) - # The file doesn't need to be saved - return True - - # The following options (`always_remove_trailing_spaces`, - # `remove_trailing_newlines` and `add_newline`) also depend on the - # `format_on_save` value. - # See spyder-ide/spyder#17716 - if self.always_remove_trailing_spaces and not self.format_on_save: - self.remove_trailing_spaces(index) - if self.remove_trailing_newlines and not self.format_on_save: - self.trim_trailing_newlines(index) - if self.add_newline and not self.format_on_save: - self.add_newline_to_file(index) - - if self.convert_eol_on_save: - # hack to account for the fact that the config file saves - # CR/LF/CRLF while set_os_eol_chars wants the os.name value. - osname_lookup = {'LF': 'posix', 'CRLF': 'nt', 'CR': 'mac'} - osname = osname_lookup[self.convert_eol_on_save_to] - self.set_os_eol_chars(osname=osname) - - try: - if self.format_on_save and finfo.editor.formatting_enabled: - # Wait for document autoformat and then save - - # Waiting for the autoformat to complete is needed - # when the file is going to be closed after saving. - # See spyder-ide/spyder#17836 - format_eventloop = finfo.editor.format_eventloop - format_timer = finfo.editor.format_timer - format_timer.setSingleShot(True) - format_timer.timeout.connect(format_eventloop.quit) - - finfo.editor.sig_stop_operation_in_progress.connect( - lambda: self._save_file(finfo)) - finfo.editor.sig_stop_operation_in_progress.connect( - format_timer.stop) - finfo.editor.sig_stop_operation_in_progress.connect( - format_eventloop.quit) - - format_timer.start(10000) - finfo.editor.format_document() - format_eventloop.exec_() - else: - self._save_file(finfo) - return True - except EnvironmentError as error: - self.msgbox = QMessageBox( - QMessageBox.Critical, - _("Save Error"), - _("Unable to save file '%s'" - "

Error message:
%s" - ) % (osp.basename(finfo.filename), - str(error)), - parent=self) - self.msgbox.exec_() - return False - - def _save_file(self, finfo): - index = self.data.index(finfo) - self._write_to_file(finfo, finfo.filename) - file_hash = self.compute_hash(finfo) - self.autosave.file_hashes[finfo.filename] = file_hash - self.autosave.remove_autosave_file(finfo.filename) - finfo.newly_created = False - self.encoding_changed.emit(finfo.encoding) - finfo.lastmodified = QFileInfo(finfo.filename).lastModified() - - # We pass self object ID as a QString, because otherwise it would - # depend on the platform: long for 64bit, int for 32bit. Replacing - # by long all the time is not working on some 32bit platforms. - # See spyder-ide/spyder#1094 and spyder-ide/spyder#1098. - # The filename is passed instead of an index in case the tabs - # have been rearranged. See spyder-ide/spyder#5703. - self.file_saved.emit(str(id(self)), - finfo.filename, finfo.filename) - - finfo.editor.document().setModified(False) - self.modification_changed(index=index) - self.analyze_script(index=index) - - finfo.editor.notify_save() - - def file_saved_in_other_editorstack(self, original_filename, filename): - """ - File was just saved in another editorstack, let's synchronize! - This avoids file being automatically reloaded. - - The original filename is passed instead of an index in case the tabs - on the editor stacks were moved and are now in a different order - see - spyder-ide/spyder#5703. - Filename is passed in case file was just saved as another name. - """ - index = self.has_filename(original_filename) - if index is None: - return - finfo = self.data[index] - finfo.newly_created = False - finfo.filename = to_text_string(filename) - finfo.lastmodified = QFileInfo(finfo.filename).lastModified() - - def select_savename(self, original_filename): - """Select a name to save a file. - - Args: - original_filename: Used in the dialog to display the current file - path and name. - - Returns: - Normalized path for the selected file name or None if no name was - selected. - """ - if self.edit_filetypes is None: - self.edit_filetypes = get_edit_filetypes() - if self.edit_filters is None: - self.edit_filters = get_edit_filters() - - # Don't use filters on KDE to not make the dialog incredible - # slow - # Fixes spyder-ide/spyder#4156. - if is_kde_desktop() and not is_anaconda(): - filters = '' - selectedfilter = '' - else: - filters = self.edit_filters - selectedfilter = get_filter(self.edit_filetypes, - osp.splitext(original_filename)[1]) - - self.redirect_stdio.emit(False) - filename, _selfilter = getsavefilename(self, _("Save file"), - original_filename, - filters=filters, - selectedfilter=selectedfilter, - options=QFileDialog.HideNameFilterDetails) - self.redirect_stdio.emit(True) - if filename: - return osp.normpath(filename) - return None - - def save_as(self, index=None): - """Save file as... - - Args: - index: self.data index for the file to save. - - Returns: - False if no file name was selected or if save() was unsuccessful. - True is save() was successful. - - Gets the new file name from select_savename(). If no name is chosen, - then the save_as() aborts. Otherwise, the current stack is checked - to see if the selected name already exists and, if so, then the tab - with that name is closed. - - The current stack (self.data) and current tabs are updated with the - new name and other file info. The text is written with the new - name using save() and the name change is propagated to the other stacks - via the file_renamed_in_data signal. - """ - if index is None: - # Save the currently edited file - index = self.get_stack_index() - finfo = self.data[index] - original_newly_created = finfo.newly_created - # The next line is necessary to avoid checking if the file exists - # While running __check_file_status - # See spyder-ide/spyder#3678 and spyder-ide/spyder#3026. - finfo.newly_created = True - original_filename = finfo.filename - filename = self.select_savename(original_filename) - if filename: - ao_index = self.has_filename(filename) - # Note: ao_index == index --> saving an untitled file - if ao_index is not None and ao_index != index: - if not self.close_file(ao_index): - return - if ao_index < index: - index -= 1 - - new_index = self.rename_in_data(original_filename, - new_filename=filename) - - # We pass self object ID as a QString, because otherwise it would - # depend on the platform: long for 64bit, int for 32bit. Replacing - # by long all the time is not working on some 32bit platforms - # See spyder-ide/spyder#1094 and spyder-ide/spyder#1098. - self.file_renamed_in_data.emit(str(id(self)), - original_filename, filename) - - ok = self.save(index=new_index, force=True) - self.refresh(new_index) - self.set_stack_index(new_index) - return ok - else: - finfo.newly_created = original_newly_created - return False - - def save_copy_as(self, index=None): - """Save copy of file as... - - Args: - index: self.data index for the file to save. - - Returns: - False if no file name was selected or if save() was unsuccessful. - True is save() was successful. - - Gets the new file name from select_savename(). If no name is chosen, - then the save_copy_as() aborts. Otherwise, the current stack is - checked to see if the selected name already exists and, if so, then the - tab with that name is closed. - - Unlike save_as(), this calls write() directly instead of using save(). - The current file and tab aren't changed at all. The copied file is - opened in a new tab. - """ - if index is None: - # Save the currently edited file - index = self.get_stack_index() - finfo = self.data[index] - original_filename = finfo.filename - filename = self.select_savename(original_filename) - if filename: - ao_index = self.has_filename(filename) - # Note: ao_index == index --> saving an untitled file - if ao_index is not None and ao_index != index: - if not self.close_file(ao_index): - return - if ao_index < index: - index -= 1 - try: - self._write_to_file(finfo, filename) - # open created copy file - self.plugin_load.emit(filename) - return True - except EnvironmentError as error: - self.msgbox = QMessageBox( - QMessageBox.Critical, - _("Save Error"), - _("Unable to save file '%s'" - "

Error message:
%s" - ) % (osp.basename(finfo.filename), - str(error)), - parent=self) - self.msgbox.exec_() - else: - return False - - def save_all(self, save_new_files=True): - """Save all opened files. - - Iterate through self.data and call save() on any modified files. - """ - all_saved = True - for index in range(self.get_stack_count()): - if self.data[index].editor.document().isModified(): - all_saved &= self.save(index, save_new_files=save_new_files) - return all_saved - - #------ Update UI - def start_stop_analysis_timer(self): - self.is_analysis_done = False - self.analysis_timer.stop() - self.analysis_timer.start() - - def analyze_script(self, index=None): - """Analyze current script for TODOs.""" - if self.is_analysis_done: - return - if index is None: - index = self.get_stack_index() - if self.data and len(self.data) > index: - finfo = self.data[index] - if self.todolist_enabled: - finfo.run_todo_finder() - self.is_analysis_done = True - - def set_todo_results(self, filename, todo_results): - """Synchronize todo results between editorstacks""" - index = self.has_filename(filename) - if index is None: - return - self.data[index].set_todo_results(todo_results) - - def get_todo_results(self): - if self.data: - return self.data[self.get_stack_index()].todo_results - - def current_changed(self, index): - """Stack index has changed""" - editor = self.get_current_editor() - if index != -1: - editor.setFocus() - logger.debug("Set focus to: %s" % editor.filename) - else: - self.reset_statusbar.emit() - self.opened_files_list_changed.emit() - - self.stack_history.refresh() - self.stack_history.remove_and_append(index) - - # Needed to avoid an error generated after moving/renaming - # files outside Spyder while in debug mode. - # See spyder-ide/spyder#8749. - try: - logger.debug("Current changed: %d - %s" % - (index, self.data[index].editor.filename)) - except IndexError: - pass - - self.update_plugin_title.emit() - # Make sure that any replace happens in the editor on top - # See spyder-ide/spyder#9688. - self.find_widget.set_editor(editor, refresh=False) - - if editor is not None: - # Needed in order to handle the close of files open in a directory - # that has been renamed. See spyder-ide/spyder#5157. - try: - line, col = editor.get_cursor_line_column() - self.current_file_changed.emit(self.data[index].filename, - editor.get_position('cursor'), - line, col) - except IndexError: - pass - - def _get_previous_file_index(self): - """Return the penultimate element of the stack history.""" - try: - return self.stack_history[-2] - except IndexError: - return None - - def tab_navigation_mru(self, forward=True): - """ - Tab navigation with "most recently used" behaviour. - - It's fired when pressing 'go to previous file' or 'go to next file' - shortcuts. - - forward: - True: move to next file - False: move to previous file - """ - self.tabs_switcher = TabSwitcherWidget(self, self.stack_history, - self.tabs) - self.tabs_switcher.show() - self.tabs_switcher.select_row(1 if forward else -1) - self.tabs_switcher.setFocus() - - def focus_changed(self): - """Editor focus has changed""" - fwidget = QApplication.focusWidget() - for finfo in self.data: - if fwidget is finfo.editor: - if finfo.editor.operation_in_progress: - self.spinner.start() - else: - self.spinner.stop() - self.refresh() - self.editor_focus_changed.emit() - - def _refresh_outlineexplorer(self, index=None, update=True, clear=False): - """Refresh outline explorer panel""" - oe = self.outlineexplorer - if oe is None: - return - if index is None: - index = self.get_stack_index() - if self.data and len(self.data) > index: - finfo = self.data[index] - oe.setEnabled(True) - oe.set_current_editor(finfo.editor.oe_proxy, - update=update, clear=clear) - if index != self.get_stack_index(): - # The last file added to the outline explorer is not the - # currently focused one in the editor stack. Therefore, - # we need to force a refresh of the outline explorer to set - # the current editor to the currently focused one in the - # editor stack. See spyder-ide/spyder#8015. - self._refresh_outlineexplorer(update=False) - return - self._sync_outlineexplorer_file_order() - - def _sync_outlineexplorer_file_order(self): - """ - Order the root file items of the outline explorer as in the tabbar - of the current EditorStack. - """ - if self.outlineexplorer is not None: - self.outlineexplorer.treewidget.set_editor_ids_order( - [finfo.editor.get_document_id() for finfo in self.data]) - - def __refresh_statusbar(self, index): - """Refreshing statusbar widgets""" - if self.data and len(self.data) > index: - finfo = self.data[index] - self.encoding_changed.emit(finfo.encoding) - # Refresh cursor position status: - line, index = finfo.editor.get_cursor_line_column() - self.sig_editor_cursor_position_changed.emit(line, index) - - def __refresh_readonly(self, index): - if self.data and len(self.data) > index: - finfo = self.data[index] - read_only = not QFileInfo(finfo.filename).isWritable() - if not osp.isfile(finfo.filename): - # This is an 'untitledX.py' file (newly created) - read_only = False - elif os.name == 'nt': - try: - # Try to open the file to see if its permissions allow - # to write on it - # Fixes spyder-ide/spyder#10657 - fd = os.open(finfo.filename, os.O_RDWR) - os.close(fd) - except (IOError, OSError): - read_only = True - finfo.editor.setReadOnly(read_only) - self.readonly_changed.emit(read_only) - - def __check_file_status(self, index): - """Check if file has been changed in any way outside Spyder: - 1. removed, moved or renamed outside Spyder - 2. modified outside Spyder""" - if self.__file_status_flag: - # Avoid infinite loop: when the QMessageBox.question pops, it - # gets focus and then give it back to the CodeEditor instance, - # triggering a refresh cycle which calls this method - return - self.__file_status_flag = True - - if len(self.data) <= index: - index = self.get_stack_index() - - finfo = self.data[index] - name = osp.basename(finfo.filename) - - if finfo.newly_created: - # File was just created (not yet saved): do nothing - # (do not return because of the clean-up at the end of the method) - pass - - elif not osp.isfile(finfo.filename): - # File doesn't exist (removed, moved or offline): - self.msgbox = QMessageBox( - QMessageBox.Warning, - self.title, - _("%s is unavailable " - "(this file may have been removed, moved " - "or renamed outside Spyder)." - "
Do you want to close it?") % name, - QMessageBox.Yes | QMessageBox.No, - self) - answer = self.msgbox.exec_() - if answer == QMessageBox.Yes: - self.close_file(index) - else: - finfo.newly_created = True - finfo.editor.document().setModified(True) - self.modification_changed(index=index) - - else: - # Else, testing if it has been modified elsewhere: - lastm = QFileInfo(finfo.filename).lastModified() - if to_text_string(lastm.toString()) \ - != to_text_string(finfo.lastmodified.toString()): - if finfo.editor.document().isModified(): - self.msgbox = QMessageBox( - QMessageBox.Question, - self.title, - _("%s has been modified outside Spyder." - "
Do you want to reload it and lose all " - "your changes?") % name, - QMessageBox.Yes | QMessageBox.No, - self) - answer = self.msgbox.exec_() - if answer == QMessageBox.Yes: - self.reload(index) - else: - finfo.lastmodified = lastm - else: - self.reload(index) - - # Finally, resetting temporary flag: - self.__file_status_flag = False - - def __modify_stack_title(self): - for index, finfo in enumerate(self.data): - state = finfo.editor.document().isModified() - self.set_stack_title(index, state) - - def refresh(self, index=None): - """Refresh tabwidget""" - if index is None: - index = self.get_stack_index() - # Set current editor - if self.get_stack_count(): - index = self.get_stack_index() - finfo = self.data[index] - editor = finfo.editor - editor.setFocus() - self._refresh_outlineexplorer(index, update=False) - self.update_code_analysis_actions.emit() - self.__refresh_statusbar(index) - self.__refresh_readonly(index) - self.__check_file_status(index) - self.__modify_stack_title() - self.update_plugin_title.emit() - else: - editor = None - # Update the modification-state-dependent parameters - self.modification_changed() - # Update FindReplace binding - self.find_widget.set_editor(editor, refresh=False) - - def modification_changed(self, state=None, index=None, editor_id=None): - """ - Current editor's modification state has changed - --> change tab title depending on new modification state - --> enable/disable save/save all actions - """ - if editor_id is not None: - for index, _finfo in enumerate(self.data): - if id(_finfo.editor) == editor_id: - break - # This must be done before refreshing save/save all actions: - # (otherwise Save/Save all actions will always be enabled) - self.opened_files_list_changed.emit() - # -- - if index is None: - index = self.get_stack_index() - if index == -1: - return - finfo = self.data[index] - if state is None: - state = finfo.editor.document().isModified() or finfo.newly_created - self.set_stack_title(index, state) - # Toggle save/save all actions state - self.save_action.setEnabled(state) - self.refresh_save_all_action.emit() - # Refreshing eol mode - eol_chars = finfo.editor.get_line_separator() - self.refresh_eol_chars(eol_chars) - self.stack_history.refresh() - - def refresh_eol_chars(self, eol_chars): - os_name = sourcecode.get_os_name_from_eol_chars(eol_chars) - self.sig_refresh_eol_chars.emit(os_name) - - # ------ Load, reload - def reload(self, index): - """Reload file from disk.""" - finfo = self.data[index] - logger.debug("Reloading {}".format(finfo.filename)) - - txt, finfo.encoding = encoding.read(finfo.filename) - finfo.lastmodified = QFileInfo(finfo.filename).lastModified() - position = finfo.editor.get_position('cursor') - finfo.editor.set_text(txt) - finfo.editor.document().setModified(False) - self.autosave.file_hashes[finfo.filename] = hash(txt) - finfo.editor.set_cursor_position(position) - - #XXX CodeEditor-only: re-scan the whole text to rebuild outline - # explorer data from scratch (could be optimized because - # rehighlighting text means searching for all syntax coloring - # patterns instead of only searching for class/def patterns which - # would be sufficient for outline explorer data. - finfo.editor.rehighlight() - - def revert(self): - """Revert file from disk.""" - index = self.get_stack_index() - finfo = self.data[index] - logger.debug("Reverting {}".format(finfo.filename)) - - filename = finfo.filename - if finfo.editor.document().isModified(): - self.msgbox = QMessageBox( - QMessageBox.Warning, - self.title, - _("All changes to %s will be lost." - "
Do you want to revert file from disk?" - ) % osp.basename(filename), - QMessageBox.Yes | QMessageBox.No, - self) - answer = self.msgbox.exec_() - if answer != QMessageBox.Yes: - return - self.reload(index) - - def create_new_editor(self, fname, enc, txt, set_current, new=False, - cloned_from=None, add_where='end'): - """ - Create a new editor instance - Returns finfo object (instead of editor as in previous releases) - """ - editor = codeeditor.CodeEditor(self) - editor.go_to_definition.connect( - lambda fname, line, column: self.sig_go_to_definition.emit( - fname, line, column)) - - finfo = FileInfo(fname, enc, editor, new, self.threadmanager) - - self.add_to_data(finfo, set_current, add_where) - finfo.sig_send_to_help.connect(self.send_to_help) - finfo.sig_show_object_info.connect(self.inspect_current_object) - finfo.todo_results_changed.connect( - lambda: self.todo_results_changed.emit()) - finfo.edit_goto.connect(lambda fname, lineno, name: - self.edit_goto.emit(fname, lineno, name)) - finfo.sig_save_bookmarks.connect(lambda s1, s2: - self.sig_save_bookmarks.emit(s1, s2)) - editor.sig_run_selection.connect(self.run_selection) - editor.sig_run_to_line.connect(self.run_to_line) - editor.sig_run_from_line.connect(self.run_from_line) - editor.sig_run_cell.connect(self.run_cell) - editor.sig_debug_cell.connect(self.debug_cell) - editor.sig_run_cell_and_advance.connect(self.run_cell_and_advance) - editor.sig_re_run_last_cell.connect(self.re_run_last_cell) - editor.sig_new_file.connect(self.sig_new_file) - editor.sig_breakpoints_saved.connect(self.sig_breakpoints_saved) - editor.sig_process_code_analysis.connect( - lambda: self.update_code_analysis_actions.emit()) - editor.sig_refresh_formatting.connect(self.sig_refresh_formatting) - language = get_file_language(fname, txt) - editor.setup_editor( - linenumbers=self.linenumbers_enabled, - show_blanks=self.blanks_enabled, - underline_errors=self.underline_errors_enabled, - scroll_past_end=self.scrollpastend_enabled, - edge_line=self.edgeline_enabled, - edge_line_columns=self.edgeline_columns, - language=language, - markers=self.has_markers(), - font=self.default_font, - color_scheme=self.color_scheme, - wrap=self.wrap_enabled, - tab_mode=self.tabmode_enabled, - strip_mode=self.stripmode_enabled, - intelligent_backspace=self.intelligent_backspace_enabled, - automatic_completions=self.automatic_completions_enabled, - automatic_completions_after_chars=self.automatic_completion_chars, - automatic_completions_after_ms=self.automatic_completion_ms, - code_snippets=self.code_snippets_enabled, - completions_hint=self.completions_hint_enabled, - completions_hint_after_ms=self.completions_hint_after_ms, - hover_hints=self.hover_hints_enabled, - highlight_current_line=self.highlight_current_line_enabled, - highlight_current_cell=self.highlight_current_cell_enabled, - occurrence_highlighting=self.occurrence_highlighting_enabled, - occurrence_timeout=self.occurrence_highlighting_timeout, - close_parentheses=self.close_parentheses_enabled, - close_quotes=self.close_quotes_enabled, - add_colons=self.add_colons_enabled, - auto_unindent=self.auto_unindent_enabled, - indent_chars=self.indent_chars, - tab_stop_width_spaces=self.tab_stop_width_spaces, - cloned_from=cloned_from, - filename=fname, - show_class_func_dropdown=self.show_class_func_dropdown, - indent_guides=self.indent_guides, - folding=self.code_folding_enabled, - remove_trailing_spaces=self.always_remove_trailing_spaces, - remove_trailing_newlines=self.remove_trailing_newlines, - add_newline=self.add_newline, - format_on_save=self.format_on_save - ) - if cloned_from is None: - editor.set_text(txt) - editor.document().setModified(False) - finfo.text_changed_at.connect( - lambda fname, position: - self.text_changed_at.emit(fname, position)) - editor.sig_cursor_position_changed.connect( - self.editor_cursor_position_changed) - editor.textChanged.connect(self.start_stop_analysis_timer) - - # Register external panels - for panel_class, args, kwargs, position in self.external_panels: - self.register_panel( - panel_class, *args, position=position, **kwargs) - - def perform_completion_request(lang, method, params): - self.sig_perform_completion_request.emit(lang, method, params) - - editor.sig_perform_completion_request.connect( - perform_completion_request) - editor.sig_start_operation_in_progress.connect(self.spinner.start) - editor.sig_stop_operation_in_progress.connect(self.spinner.stop) - editor.modificationChanged.connect( - lambda state: self.modification_changed( - state, editor_id=id(editor))) - editor.focus_in.connect(self.focus_changed) - editor.zoom_in.connect(lambda: self.zoom_in.emit()) - editor.zoom_out.connect(lambda: self.zoom_out.emit()) - editor.zoom_reset.connect(lambda: self.zoom_reset.emit()) - editor.sig_eol_chars_changed.connect( - lambda eol_chars: self.refresh_eol_chars(eol_chars)) - editor.sig_next_cursor.connect(self.sig_next_cursor) - editor.sig_prev_cursor.connect(self.sig_prev_cursor) - - self.find_widget.set_editor(editor) - - self.refresh_file_dependent_actions.emit() - self.modification_changed(index=self.data.index(finfo)) - - # To update the outline explorer. - editor.oe_proxy = OutlineExplorerProxyEditor(editor, editor.filename) - if self.outlineexplorer is not None: - self.outlineexplorer.register_editor(editor.oe_proxy) - - # Needs to reset the highlighting on startup in case the PygmentsSH - # is in use - editor.run_pygments_highlighter() - options = { - 'language': editor.language, - 'filename': editor.filename, - 'codeeditor': editor - } - self.sig_open_file.emit(options) - if self.get_stack_index() == 0: - self.current_changed(0) - - return finfo - - def editor_cursor_position_changed(self, line, index): - """Cursor position of one of the editor in the stack has changed""" - self.sig_editor_cursor_position_changed.emit(line, index) - - @Slot(str, str, bool) - def send_to_help(self, name, signature, force=False): - """qstr1: obj_text, qstr2: argpspec, qstr3: note, qstr4: doc_text""" - if not force and not self.help_enabled: - return - - editor = self.get_current_editor() - language = editor.language.lower() - signature = to_text_string(signature) - signature = unicodedata.normalize("NFKD", signature) - parts = signature.split('\n\n') - definition = parts[0] - documentation = '\n\n'.join(parts[1:]) - args = '' - - if '(' in definition and language == 'python': - args = definition[definition.find('('):] - else: - documentation = signature - - doc = { - 'obj_text': '', - 'name': name, - 'argspec': args, - 'note': '', - 'docstring': documentation, - 'force_refresh': force, - 'path': editor.filename - } - self.sig_help_requested.emit(doc) - - def new(self, filename, encoding, text, default_content=False, - empty=False): - """ - Create new filename with *encoding* and *text* - """ - finfo = self.create_new_editor(filename, encoding, text, - set_current=False, new=True) - finfo.editor.set_cursor_position('eof') - if not empty: - finfo.editor.insert_text(os.linesep) - if default_content: - finfo.default = True - finfo.editor.document().setModified(False) - return finfo - - def load(self, filename, set_current=True, add_where='end', - processevents=True): - """ - Load filename, create an editor instance and return it - - This also sets the hash of the loaded file in the autosave component. - - *Warning* This is loading file, creating editor but not executing - the source code analysis -- the analysis must be done by the editor - plugin (in case multiple editorstack instances are handled) - """ - filename = osp.abspath(to_text_string(filename)) - if processevents: - self.starting_long_process.emit(_("Loading %s...") % filename) - text, enc = encoding.read(filename) - self.autosave.file_hashes[filename] = hash(text) - finfo = self.create_new_editor(filename, enc, text, set_current, - add_where=add_where) - index = self.data.index(finfo) - if processevents: - self.ending_long_process.emit("") - if self.isVisible() and self.checkeolchars_enabled \ - and sourcecode.has_mixed_eol_chars(text): - name = osp.basename(filename) - self.msgbox = QMessageBox( - QMessageBox.Warning, - self.title, - _("%s contains mixed end-of-line " - "characters.
Spyder will fix this " - "automatically.") % name, - QMessageBox.Ok, - self) - self.msgbox.exec_() - self.set_os_eol_chars(index) - self.is_analysis_done = False - self.analyze_script(index) - finfo.editor.set_sync_symbols_and_folding_timeout() - return finfo - - def set_os_eol_chars(self, index=None, osname=None): - """ - Sets the EOL character(s) based on the operating system. - - If `osname` is None, then the default line endings for the current - operating system will be used. - - `osname` can be one of: 'posix', 'nt', 'mac'. - """ - if osname is None: - if os.name == 'nt': - osname = 'nt' - elif sys.platform == 'darwin': - osname = 'mac' - else: - osname = 'posix' - - if index is None: - index = self.get_stack_index() - - finfo = self.data[index] - eol_chars = sourcecode.get_eol_chars_from_os_name(osname) - logger.debug(f"Set OS eol chars {eol_chars} for file {finfo.filename}") - finfo.editor.set_eol_chars(eol_chars=eol_chars) - finfo.editor.document().setModified(True) - - def remove_trailing_spaces(self, index=None): - """Remove trailing spaces""" - if index is None: - index = self.get_stack_index() - finfo = self.data[index] - logger.debug(f"Remove trailing spaces for file {finfo.filename}") - finfo.editor.trim_trailing_spaces() - - def trim_trailing_newlines(self, index=None): - if index is None: - index = self.get_stack_index() - finfo = self.data[index] - logger.debug(f"Trim trailing new lines for file {finfo.filename}") - finfo.editor.trim_trailing_newlines() - - def add_newline_to_file(self, index=None): - if index is None: - index = self.get_stack_index() - finfo = self.data[index] - logger.debug(f"Add new line to file {finfo.filename}") - finfo.editor.add_newline_to_file() - - def fix_indentation(self, index=None): - """Replace tab characters by spaces""" - if index is None: - index = self.get_stack_index() - finfo = self.data[index] - logger.debug(f"Fix indentation for file {finfo.filename}") - finfo.editor.fix_indentation() - - def format_document_or_selection(self, index=None): - if index is None: - index = self.get_stack_index() - finfo = self.data[index] - logger.debug(f"Run formatting in file {finfo.filename}") - finfo.editor.format_document_or_range() - - # ------ Run - def _run_lines_cursor(self, direction): - """ Select and run all lines from cursor in given direction""" - editor = self.get_current_editor() - - # Move cursor to start of line then move to beginning or end of - # document with KeepAnchor - cursor = editor.textCursor() - cursor.movePosition(QTextCursor.StartOfLine) - - if direction == 'up': - cursor.movePosition(QTextCursor.Start, QTextCursor.KeepAnchor) - elif direction == 'down': - cursor.movePosition(QTextCursor.End, QTextCursor.KeepAnchor) - - code_text = editor.get_selection_as_executable_code(cursor) - if code_text: - self.exec_in_extconsole.emit(code_text.rstrip(), - self.focus_to_editor) - - def run_to_line(self): - """ - Run all lines from the beginning up to, but not including, current - line. - """ - self._run_lines_cursor(direction='up') - - def run_from_line(self): - """ - Run all lines from and including the current line to the end of - the document. - """ - self._run_lines_cursor(direction='down') - - def run_selection(self): - """ - Run selected text or current line in console. - - If some text is selected, then execute that text in console. - - If no text is selected, then execute current line, unless current line - is empty. Then, advance cursor to next line. If cursor is on last line - and that line is not empty, then add a new blank line and move the - cursor there. If cursor is on last line and that line is empty, then do - not move cursor. - """ - text = self.get_current_editor().get_selection_as_executable_code() - if text: - self.exec_in_extconsole.emit(text.rstrip(), self.focus_to_editor) - return - editor = self.get_current_editor() - line = editor.get_current_line() - text = line.lstrip() - if text: - self.exec_in_extconsole.emit(text, self.focus_to_editor) - if editor.is_cursor_on_last_line() and text: - editor.append(editor.get_line_separator()) - editor.move_cursor_to_next('line', 'down') - - def run_cell(self, debug=False): - """Run current cell.""" - text, block = self.get_current_editor().get_cell_as_executable_code() - finfo = self.get_current_finfo() - editor = self.get_current_editor() - name = cell_name(block) - filename = finfo.filename - - self._run_cell_text(text, editor, (filename, name), debug) - - def debug_cell(self): - """Debug current cell.""" - self.run_cell(debug=True) - - def run_cell_and_advance(self): - """Run current cell and advance to the next one""" - self.run_cell() - self.advance_cell() - - def advance_cell(self, reverse=False): - """Advance to the next cell. - - reverse = True --> go to previous cell. - """ - if not reverse: - move_func = self.get_current_editor().go_to_next_cell - else: - move_func = self.get_current_editor().go_to_previous_cell - - move_func() - - def re_run_last_cell(self): - """Run the previous cell again.""" - if self.last_cell_call is None: - return - filename, cell_name = self.last_cell_call - index = self.has_filename(filename) - if index is None: - return - editor = self.data[index].editor - - try: - text = editor.get_cell_code(cell_name) - except RuntimeError: - return - - self._run_cell_text(text, editor, (filename, cell_name)) - - def _run_cell_text(self, text, editor, cell_id, debug=False): - """Run cell code in the console. - - Cell code is run in the console by copying it to the console if - `self.run_cell_copy` is ``True`` otherwise by using the `run_cell` - function. - - Parameters - ---------- - text : str - The code in the cell as a string. - line : int - The starting line number of the cell in the file. - """ - (filename, cell_name) = cell_id - if editor.is_python_or_ipython(): - args = (text, cell_name, filename, self.run_cell_copy, - self.focus_to_editor) - if debug: - self.debug_cell_in_ipyclient.emit(*args) - else: - self.run_cell_in_ipyclient.emit(*args) - - # ------ Drag and drop - def dragEnterEvent(self, event): - """ - Reimplemented Qt method. - - Inform Qt about the types of data that the widget accepts. - """ - logger.debug("dragEnterEvent was received") - source = event.mimeData() - # The second check is necessary on Windows, where source.hasUrls() - # can return True but source.urls() is [] - # The third check is needed since a file could be dropped from - # compressed files. In Windows mimedata2url(source) returns None - # Fixes spyder-ide/spyder#5218. - has_urls = source.hasUrls() - has_text = source.hasText() - urls = source.urls() - all_urls = mimedata2url(source) - logger.debug("Drag event source has_urls: {}".format(has_urls)) - logger.debug("Drag event source urls: {}".format(urls)) - logger.debug("Drag event source all_urls: {}".format(all_urls)) - logger.debug("Drag event source has_text: {}".format(has_text)) - if has_urls and urls and all_urls: - text = [encoding.is_text_file(url) for url in all_urls] - logger.debug("Accept proposed action?: {}".format(any(text))) - if any(text): - event.acceptProposedAction() - else: - event.ignore() - elif source.hasText(): - event.acceptProposedAction() - elif os.name == 'nt': - # This covers cases like dragging from compressed files, - # which can be opened by the Editor if they are plain - # text, but doesn't come with url info. - # Fixes spyder-ide/spyder#2032. - logger.debug("Accept proposed action on Windows") - event.acceptProposedAction() - else: - logger.debug("Ignore drag event") - event.ignore() - - def dropEvent(self, event): - """ - Reimplement Qt method. - - Unpack dropped data and handle it. - """ - logger.debug("dropEvent was received") - source = event.mimeData() - # The second check is necessary when mimedata2url(source) - # returns None. - # Fixes spyder-ide/spyder#7742. - if source.hasUrls() and mimedata2url(source): - files = mimedata2url(source) - files = [f for f in files if encoding.is_text_file(f)] - files = set(files or []) - for fname in files: - self.plugin_load.emit(fname) - elif source.hasText(): - editor = self.get_current_editor() - if editor is not None: - editor.insert_text(source.text()) - else: - event.ignore() - event.acceptProposedAction() - - def register_panel(self, panel_class, *args, - position=Panel.Position.LEFT, **kwargs): - """Register a panel in all codeeditors.""" - if (panel_class, args, kwargs, position) not in self.external_panels: - self.external_panels.append((panel_class, args, kwargs, position)) - for finfo in self.data: - cur_panel = finfo.editor.panels.register( - panel_class(*args, **kwargs), position=position) - if not cur_panel.isVisible(): - cur_panel.setVisible(True) - - -class EditorSplitter(QSplitter): - """QSplitter for editor windows.""" - - def __init__(self, parent, plugin, menu_actions, first=False, - register_editorstack_cb=None, unregister_editorstack_cb=None): - """Create a splitter for dividing an editor window into panels. - - Adds a new EditorStack instance to this splitter. If it's not - the first splitter, clones the current EditorStack from the plugin. - - Args: - parent: Parent widget. - plugin: Plugin this widget belongs to. - menu_actions: QActions to include from the parent. - first: Boolean if this is the first splitter in the editor. - register_editorstack_cb: Callback to register the EditorStack. - Defaults to plugin.register_editorstack() to - register the EditorStack with the Editor plugin. - unregister_editorstack_cb: Callback to unregister the EditorStack. - Defaults to plugin.unregister_editorstack() to - unregister the EditorStack with the Editor plugin. - """ - - QSplitter.__init__(self, parent) - self.setAttribute(Qt.WA_DeleteOnClose) - self.setChildrenCollapsible(False) - - self.toolbar_list = None - self.menu_list = None - - self.plugin = plugin - - if register_editorstack_cb is None: - register_editorstack_cb = self.plugin.register_editorstack - self.register_editorstack_cb = register_editorstack_cb - if unregister_editorstack_cb is None: - unregister_editorstack_cb = self.plugin.unregister_editorstack - self.unregister_editorstack_cb = unregister_editorstack_cb - - self.menu_actions = menu_actions - self.editorstack = EditorStack(self, menu_actions) - self.register_editorstack_cb(self.editorstack) - if not first: - self.plugin.clone_editorstack(editorstack=self.editorstack) - self.editorstack.destroyed.connect(lambda: self.editorstack_closed()) - self.editorstack.sig_split_vertically.connect( - lambda: self.split(orientation=Qt.Vertical)) - self.editorstack.sig_split_horizontally.connect( - lambda: self.split(orientation=Qt.Horizontal)) - self.addWidget(self.editorstack) - - if not running_under_pytest(): - self.editorstack.set_color_scheme(plugin.get_color_scheme()) - - self.setStyleSheet(self._stylesheet) - - def closeEvent(self, event): - """Override QWidget closeEvent(). - - This event handler is called with the given event when Qt - receives a window close request from a top-level widget. - """ - QSplitter.closeEvent(self, event) - - def __give_focus_to_remaining_editor(self): - focus_widget = self.plugin.get_focus_widget() - if focus_widget is not None: - focus_widget.setFocus() - - def editorstack_closed(self): - try: - logger.debug("method 'editorstack_closed':") - logger.debug(" self : %r" % self) - self.unregister_editorstack_cb(self.editorstack) - self.editorstack = None - close_splitter = self.count() == 1 - if close_splitter: - # editorstack just closed was the last widget in this QSplitter - self.close() - return - self.__give_focus_to_remaining_editor() - except (RuntimeError, AttributeError): - # editorsplitter has been destroyed (happens when closing a - # EditorMainWindow instance) - return - - def editorsplitter_closed(self): - logger.debug("method 'editorsplitter_closed':") - logger.debug(" self : %r" % self) - try: - close_splitter = self.count() == 1 and self.editorstack is None - except RuntimeError: - # editorsplitter has been destroyed (happens when closing a - # EditorMainWindow instance) - return - if close_splitter: - # editorsplitter just closed was the last widget in this QSplitter - self.close() - return - elif self.count() == 2 and self.editorstack: - # back to the initial state: a single editorstack instance, - # as a single widget in this QSplitter: orientation may be changed - self.editorstack.reset_orientation() - self.__give_focus_to_remaining_editor() - - def split(self, orientation=Qt.Vertical): - """Create and attach a new EditorSplitter to the current EditorSplitter. - - The new EditorSplitter widget will contain an EditorStack that - is a clone of the current EditorStack. - - A single EditorSplitter instance can be split multiple times, but the - orientation will be the same for all the direct splits. If one of - the child splits is split, then that split can have a different - orientation. - """ - self.setOrientation(orientation) - self.editorstack.set_orientation(orientation) - editorsplitter = EditorSplitter(self.parent(), self.plugin, - self.menu_actions, - register_editorstack_cb=self.register_editorstack_cb, - unregister_editorstack_cb=self.unregister_editorstack_cb) - self.addWidget(editorsplitter) - editorsplitter.destroyed.connect(self.editorsplitter_closed) - current_editor = editorsplitter.editorstack.get_current_editor() - if current_editor is not None: - current_editor.setFocus() - - def iter_editorstacks(self): - """Return the editor stacks for this splitter and every first child. - - Note: If a splitter contains more than one splitter as a direct - child, only the first child's editor stack is included. - - Returns: - List of tuples containing (EditorStack instance, orientation). - """ - editorstacks = [(self.widget(0), self.orientation())] - if self.count() > 1: - editorsplitter = self.widget(1) - editorstacks += editorsplitter.iter_editorstacks() - return editorstacks - - def get_layout_settings(self): - """Return the layout state for this splitter and its children. - - Record the current state, including file names and current line - numbers, of the splitter panels. - - Returns: - A dictionary containing keys {hexstate, sizes, splitsettings}. - hexstate: String of saveState() for self. - sizes: List for size() for self. - splitsettings: List of tuples of the form - (orientation, cfname, clines) for each EditorSplitter - and its EditorStack. - orientation: orientation() for the editor - splitter (which may be a child of self). - cfname: EditorStack current file name. - clines: Current line number for each file in the - EditorStack. - """ - splitsettings = [] - for editorstack, orientation in self.iter_editorstacks(): - clines = [] - cfname = '' - # XXX - this overrides value from the loop to always be False? - orientation = False - if hasattr(editorstack, 'data'): - clines = [finfo.editor.get_cursor_line_number() - for finfo in editorstack.data] - cfname = editorstack.get_current_filename() - splitsettings.append((orientation == Qt.Vertical, cfname, clines)) - return dict(hexstate=qbytearray_to_str(self.saveState()), - sizes=self.sizes(), splitsettings=splitsettings) - - def set_layout_settings(self, settings, dont_goto=None): - """Restore layout state for the splitter panels. - - Apply the settings to restore a saved layout within the editor. If - the splitsettings key doesn't exist, then return without restoring - any settings. - - The current EditorSplitter (self) calls split() for each element - in split_settings, thus recreating the splitter panels from the saved - state. split() also clones the editorstack, which is then - iterated over to restore the saved line numbers on each file. - - The size and positioning of each splitter panel is restored from - hexstate. - - Args: - settings: A dictionary with keys {hexstate, sizes, orientation} - that define the layout for the EditorSplitter panels. - dont_goto: Defaults to None, which positions the cursor to the - end of the editor. If there's a value, positions the - cursor on the saved line number for each editor. - """ - splitsettings = settings.get('splitsettings') - if splitsettings is None: - return - splitter = self - editor = None - for i, (is_vertical, cfname, clines) in enumerate(splitsettings): - if i > 0: - splitter.split(Qt.Vertical if is_vertical else Qt.Horizontal) - splitter = splitter.widget(1) - editorstack = splitter.widget(0) - for j, finfo in enumerate(editorstack.data): - editor = finfo.editor - # TODO: go_to_line is not working properly (the line it jumps - # to is not the corresponding to that file). This will be fixed - # in a future PR (which will fix spyder-ide/spyder#3857). - if dont_goto is not None: - # Skip go to line for first file because is already there. - pass - else: - try: - editor.go_to_line(clines[j]) - except IndexError: - pass - hexstate = settings.get('hexstate') - if hexstate is not None: - self.restoreState( QByteArray().fromHex( - str(hexstate).encode('utf-8')) ) - sizes = settings.get('sizes') - if sizes is not None: - self.setSizes(sizes) - if editor is not None: - editor.clearFocus() - editor.setFocus() - - @property - def _stylesheet(self): - css = qstylizer.style.StyleSheet() - css.QSplitter.setValues( - background=QStylePalette.COLOR_BACKGROUND_1 - ) - return css.toString() - - -class EditorWidget(QSplitter): - CONF_SECTION = 'editor' - - def __init__(self, parent, plugin, menu_actions): - QSplitter.__init__(self, parent) - self.setAttribute(Qt.WA_DeleteOnClose) - - statusbar = parent.statusBar() # Create a status bar - self.vcs_status = VCSStatus(self) - self.cursorpos_status = CursorPositionStatus(self) - self.encoding_status = EncodingStatus(self) - self.eol_status = EOLStatus(self) - self.readwrite_status = ReadWriteStatus(self) - - statusbar.insertPermanentWidget(0, self.readwrite_status) - statusbar.insertPermanentWidget(0, self.eol_status) - statusbar.insertPermanentWidget(0, self.encoding_status) - statusbar.insertPermanentWidget(0, self.cursorpos_status) - statusbar.insertPermanentWidget(0, self.vcs_status) - - self.editorstacks = [] - - self.plugin = plugin - - self.find_widget = FindReplace(self, enable_replace=True) - self.plugin.register_widget_shortcuts(self.find_widget) - self.find_widget.hide() - - # TODO: Check this initialization once the editor is migrated to the - # new API - self.outlineexplorer = OutlineExplorerWidget( - 'outline_explorer', - plugin, - self, - context=f'editor_window_{str(id(self))}' - ) - self.outlineexplorer.edit_goto.connect( - lambda filenames, goto, word: - plugin.load(filenames=filenames, goto=goto, word=word, - editorwindow=self.parent())) - - editor_widgets = QWidget(self) - editor_layout = QVBoxLayout() - editor_layout.setContentsMargins(0, 0, 0, 0) - editor_widgets.setLayout(editor_layout) - editorsplitter = EditorSplitter(self, plugin, menu_actions, - register_editorstack_cb=self.register_editorstack, - unregister_editorstack_cb=self.unregister_editorstack) - self.editorsplitter = editorsplitter - editor_layout.addWidget(editorsplitter) - editor_layout.addWidget(self.find_widget) - - splitter = QSplitter(self) - splitter.setContentsMargins(0, 0, 0, 0) - splitter.addWidget(editor_widgets) - splitter.addWidget(self.outlineexplorer) - splitter.setStretchFactor(0, 5) - splitter.setStretchFactor(1, 1) - - def register_editorstack(self, editorstack): - self.editorstacks.append(editorstack) - logger.debug("EditorWidget.register_editorstack: %r" % editorstack) - self.__print_editorstacks() - self.plugin.last_focused_editorstack[self.parent()] = editorstack - editorstack.set_closable(len(self.editorstacks) > 1) - editorstack.set_outlineexplorer(self.outlineexplorer) - editorstack.set_find_widget(self.find_widget) - editorstack.reset_statusbar.connect(self.readwrite_status.hide) - editorstack.reset_statusbar.connect(self.encoding_status.hide) - editorstack.reset_statusbar.connect(self.cursorpos_status.hide) - editorstack.readonly_changed.connect( - self.readwrite_status.update_readonly) - editorstack.encoding_changed.connect( - self.encoding_status.update_encoding) - editorstack.sig_editor_cursor_position_changed.connect( - self.cursorpos_status.update_cursor_position) - editorstack.sig_refresh_eol_chars.connect(self.eol_status.update_eol) - self.plugin.register_editorstack(editorstack) - - def __print_editorstacks(self): - logger.debug("%d editorstack(s) in editorwidget:" % - len(self.editorstacks)) - for edst in self.editorstacks: - logger.debug(" %r" % edst) - - def unregister_editorstack(self, editorstack): - logger.debug("EditorWidget.unregister_editorstack: %r" % editorstack) - self.plugin.unregister_editorstack(editorstack) - self.editorstacks.pop(self.editorstacks.index(editorstack)) - self.__print_editorstacks() - - -class EditorMainWindow(QMainWindow): - def __init__( - self, plugin, menu_actions, toolbar_list, menu_list, parent=None): - # Parent needs to be `None` if the the created widget is meant to be - # independent. See spyder-ide/spyder#17803 - QMainWindow.__init__(self, parent) - self.setAttribute(Qt.WA_DeleteOnClose) - - self.plugin = plugin - self.window_size = None - - self.editorwidget = EditorWidget(self, plugin, menu_actions) - self.setCentralWidget(self.editorwidget) - - # Setting interface theme - self.setStyleSheet(str(APP_STYLESHEET)) - - # Give focus to current editor to update/show all status bar widgets - editorstack = self.editorwidget.editorsplitter.editorstack - editor = editorstack.get_current_editor() - if editor is not None: - editor.setFocus() - - self.setWindowTitle("Spyder - %s" % plugin.windowTitle()) - self.setWindowIcon(plugin.windowIcon()) - - if toolbar_list: - self.toolbars = [] - for title, object_name, actions in toolbar_list: - toolbar = self.addToolBar(title) - toolbar.setObjectName(object_name) - toolbar.setStyleSheet(str(APP_TOOLBAR_STYLESHEET)) - toolbar.setMovable(False) - add_actions(toolbar, actions) - self.toolbars.append(toolbar) - if menu_list: - quit_action = create_action(self, _("Close window"), - icon=ima.icon("close_pane"), - tip=_("Close this window"), - triggered=self.close) - self.menus = [] - for index, (title, actions) in enumerate(menu_list): - menu = self.menuBar().addMenu(title) - if index == 0: - # File menu - add_actions(menu, actions+[None, quit_action]) - else: - add_actions(menu, actions) - self.menus.append(menu) - - def get_toolbars(self): - """Get the toolbars.""" - return self.toolbars - - def add_toolbars_to_menu(self, menu_title, actions): - """Add toolbars to a menu.""" - # Six is the position of the view menu in menus list - # that you can find in plugins/editor.py setup_other_windows. - view_menu = self.menus[6] - view_menu.setObjectName('checkbox-padding') - if actions == self.toolbars and view_menu: - toolbars = [] - for toolbar in self.toolbars: - action = toolbar.toggleViewAction() - toolbars.append(action) - add_actions(view_menu, toolbars) - - def load_toolbars(self): - """Loads the last visible toolbars from the .ini file.""" - toolbars_names = CONF.get('main', 'last_visible_toolbars', default=[]) - if toolbars_names: - dic = {} - for toolbar in self.toolbars: - dic[toolbar.objectName()] = toolbar - toolbar.toggleViewAction().setChecked(False) - toolbar.setVisible(False) - for name in toolbars_names: - if name in dic: - dic[name].toggleViewAction().setChecked(True) - dic[name].setVisible(True) - - def resizeEvent(self, event): - """Reimplement Qt method""" - if not self.isMaximized() and not self.isFullScreen(): - self.window_size = self.size() - QMainWindow.resizeEvent(self, event) - - def closeEvent(self, event): - """Reimplement Qt method""" - if self.plugin._undocked_window is not None: - self.plugin.dockwidget.setWidget(self.plugin) - self.plugin.dockwidget.setVisible(True) - self.plugin.switch_to_plugin() - QMainWindow.closeEvent(self, event) - if self.plugin._undocked_window is not None: - self.plugin._undocked_window = None - - def get_layout_settings(self): - """Return layout state""" - splitsettings = self.editorwidget.editorsplitter.get_layout_settings() - return dict(size=(self.window_size.width(), self.window_size.height()), - pos=(self.pos().x(), self.pos().y()), - is_maximized=self.isMaximized(), - is_fullscreen=self.isFullScreen(), - hexstate=qbytearray_to_str(self.saveState()), - splitsettings=splitsettings) - - def set_layout_settings(self, settings): - """Restore layout state""" - size = settings.get('size') - if size is not None: - self.resize( QSize(*size) ) - self.window_size = self.size() - pos = settings.get('pos') - if pos is not None: - self.move( QPoint(*pos) ) - hexstate = settings.get('hexstate') - if hexstate is not None: - self.restoreState( QByteArray().fromHex( - str(hexstate).encode('utf-8')) ) - if settings.get('is_maximized'): - self.setWindowState(Qt.WindowMaximized) - if settings.get('is_fullscreen'): - self.setWindowState(Qt.WindowFullScreen) - splitsettings = settings.get('splitsettings') - if splitsettings is not None: - self.editorwidget.editorsplitter.set_layout_settings(splitsettings) - - -class EditorPluginExample(QSplitter): - def __init__(self): - QSplitter.__init__(self) - - self._dock_action = None - self._undock_action = None - self._close_plugin_action = None - self._undocked_window = None - self._lock_unlock_action = None - menu_actions = [] - - self.editorstacks = [] - self.editorwindows = [] - - self.last_focused_editorstack = {} # fake - - self.find_widget = FindReplace(self, enable_replace=True) - self.outlineexplorer = OutlineExplorerWidget(None, self, self) - self.outlineexplorer.edit_goto.connect(self.go_to_file) - self.editor_splitter = EditorSplitter(self, self, menu_actions, - first=True) - - editor_widgets = QWidget(self) - editor_layout = QVBoxLayout() - editor_layout.setContentsMargins(0, 0, 0, 0) - editor_widgets.setLayout(editor_layout) - editor_layout.addWidget(self.editor_splitter) - editor_layout.addWidget(self.find_widget) - - self.setContentsMargins(0, 0, 0, 0) - self.addWidget(editor_widgets) - self.addWidget(self.outlineexplorer) - - self.setStretchFactor(0, 5) - self.setStretchFactor(1, 1) - - self.menu_actions = menu_actions - self.toolbar_list = None - self.menu_list = None - self.setup_window([], []) - - def go_to_file(self, fname, lineno, text='', start_column=None): - editorstack = self.editorstacks[0] - editorstack.set_current_filename(to_text_string(fname)) - editor = editorstack.get_current_editor() - editor.go_to_line(lineno, word=text, start_column=start_column) - - def closeEvent(self, event): - for win in self.editorwindows[:]: - win.close() - logger.debug("%d: %r" % (len(self.editorwindows), self.editorwindows)) - logger.debug("%d: %r" % (len(self.editorstacks), self.editorstacks)) - event.accept() - - def load(self, fname): - QApplication.processEvents() - editorstack = self.editorstacks[0] - editorstack.load(fname) - editorstack.analyze_script() - - def register_editorstack(self, editorstack): - logger.debug("FakePlugin.register_editorstack: %r" % editorstack) - self.editorstacks.append(editorstack) - if self.isAncestorOf(editorstack): - # editorstack is a child of the Editor plugin - editorstack.set_closable(len(self.editorstacks) > 1) - editorstack.set_outlineexplorer(self.outlineexplorer) - editorstack.set_find_widget(self.find_widget) - oe_btn = create_toolbutton(self) - editorstack.add_corner_widgets_to_tabbar([5, oe_btn]) - - action = QAction(self) - editorstack.set_io_actions(action, action, action, action) - font = QFont("Courier New") - font.setPointSize(10) - editorstack.set_default_font(font, color_scheme='Spyder') - - editorstack.sig_close_file.connect(self.close_file_in_all_editorstacks) - editorstack.file_saved.connect(self.file_saved_in_editorstack) - editorstack.file_renamed_in_data.connect( - self.file_renamed_in_data_in_editorstack) - editorstack.plugin_load.connect(self.load) - - def unregister_editorstack(self, editorstack): - logger.debug("FakePlugin.unregister_editorstack: %r" % editorstack) - self.editorstacks.pop(self.editorstacks.index(editorstack)) - - def clone_editorstack(self, editorstack): - editorstack.clone_from(self.editorstacks[0]) - - def setup_window(self, toolbar_list, menu_list): - self.toolbar_list = toolbar_list - self.menu_list = menu_list - - def create_new_window(self): - window = EditorMainWindow(self, self.menu_actions, - self.toolbar_list, self.menu_list, - show_fullpath=False, show_all_files=False, - group_cells=True, show_comments=True, - sort_files_alphabetically=False) - window.resize(self.size()) - window.show() - self.register_editorwindow(window) - window.destroyed.connect(lambda: self.unregister_editorwindow(window)) - - def register_editorwindow(self, window): - logger.debug("register_editorwindowQObject*: %r" % window) - self.editorwindows.append(window) - - def unregister_editorwindow(self, window): - logger.debug("unregister_editorwindow: %r" % window) - self.editorwindows.pop(self.editorwindows.index(window)) - - def get_focus_widget(self): - pass - - @Slot(str, str) - def close_file_in_all_editorstacks(self, editorstack_id_str, filename): - for editorstack in self.editorstacks: - if str(id(editorstack)) != editorstack_id_str: - editorstack.blockSignals(True) - index = editorstack.get_index_from_filename(filename) - editorstack.close_file(index, force=True) - editorstack.blockSignals(False) - - # This method is never called in this plugin example. It's here only - # to show how to use the file_saved signal (see above). - @Slot(str, str, str) - def file_saved_in_editorstack(self, editorstack_id_str, - original_filename, filename): - """A file was saved in editorstack, this notifies others""" - for editorstack in self.editorstacks: - if str(id(editorstack)) != editorstack_id_str: - editorstack.file_saved_in_other_editorstack(original_filename, - filename) - - # This method is never called in this plugin example. It's here only - # to show how to use the file_saved signal (see above). - @Slot(str, str, str) - def file_renamed_in_data_in_editorstack(self, editorstack_id_str, - original_filename, filename): - """A file was renamed in data in editorstack, this notifies others""" - for editorstack in self.editorstacks: - if str(id(editorstack)) != editorstack_id_str: - editorstack.rename_in_data(original_filename, filename) - - def register_widget_shortcuts(self, widget): - """Fake!""" - pass - - def get_color_scheme(self): - pass - - -def test(): - from spyder.utils.qthelpers import qapplication - from spyder.config.base import get_module_path - - spyder_dir = get_module_path('spyder') - app = qapplication(test_time=8) - - test = EditorPluginExample() - test.resize(900, 700) - test.show() - - import time - t0 = time.time() - test.load(osp.join(spyder_dir, "widgets", "collectionseditor.py")) - test.load(osp.join(spyder_dir, "plugins", "editor", "widgets", - "editor.py")) - test.load(osp.join(spyder_dir, "plugins", "explorer", "widgets", - 'explorer.py')) - test.load(osp.join(spyder_dir, "plugins", "editor", "widgets", - "codeeditor.py")) - print("Elapsed time: %.3f s" % (time.time()-t0)) # spyder: test-skip - - sys.exit(app.exec_()) - - -if __name__ == "__main__": - test() +# -*- coding: utf-8 -*- +# +# Copyright © Spyder Project Contributors +# Licensed under the terms of the MIT License +# (see spyder/__init__.py for details) + +"""Editor Widget""" + +# pylint: disable=C0103 +# pylint: disable=R0903 +# pylint: disable=R0911 +# pylint: disable=R0201 + +# Standard library imports +import logging +import os +import os.path as osp +import sys +import unicodedata + +# Third party imports +import qstylizer.style +from qtpy.compat import getsavefilename +from qtpy.QtCore import (QByteArray, QFileInfo, QPoint, QSize, Qt, QTimer, + Signal, Slot) +from qtpy.QtGui import QFont, QTextCursor +from qtpy.QtWidgets import (QAction, QApplication, QFileDialog, QHBoxLayout, + QLabel, QMainWindow, QMessageBox, QMenu, + QSplitter, QVBoxLayout, QWidget, QListWidget, + QListWidgetItem, QSizePolicy, QToolBar) + +# Local imports +from spyder.api.panel import Panel +from spyder.config.base import _, running_under_pytest +from spyder.config.manager import CONF +from spyder.config.utils import (get_edit_filetypes, get_edit_filters, + get_filter, is_kde_desktop, is_anaconda) +from spyder.plugins.editor.utils.autosave import AutosaveForStack +from spyder.plugins.editor.utils.editor import get_file_language +from spyder.plugins.editor.utils.switcher import EditorSwitcherManager +from spyder.plugins.editor.widgets import codeeditor +from spyder.plugins.editor.widgets.editorstack_helpers import ( + ThreadManager, FileInfo, StackHistory) +from spyder.plugins.editor.widgets.status import (CursorPositionStatus, + EncodingStatus, EOLStatus, + ReadWriteStatus, VCSStatus) +from spyder.plugins.explorer.widgets.explorer import ( + show_in_external_file_explorer) +from spyder.plugins.explorer.widgets.utils import fixpath +from spyder.plugins.outlineexplorer.main_widget import OutlineExplorerWidget +from spyder.plugins.outlineexplorer.editor import OutlineExplorerProxyEditor +from spyder.plugins.outlineexplorer.api import cell_name +from spyder.py3compat import qbytearray_to_str, to_text_string +from spyder.utils import encoding, sourcecode, syntaxhighlighters +from spyder.utils.icon_manager import ima +from spyder.utils.palette import QStylePalette +from spyder.utils.qthelpers import (add_actions, create_action, + create_toolbutton, MENU_SEPARATOR, + mimedata2url, set_menu_icons, + create_waitspinner) +from spyder.utils.stylesheet import ( + APP_STYLESHEET, APP_TOOLBAR_STYLESHEET, PANES_TABBAR_STYLESHEET) +from spyder.widgets.findreplace import FindReplace +from spyder.widgets.tabs import BaseTabs + + +logger = logging.getLogger(__name__) + + +class TabSwitcherWidget(QListWidget): + """Show tabs in mru order and change between them.""" + + def __init__(self, parent, stack_history, tabs): + QListWidget.__init__(self, parent) + self.setWindowFlags(Qt.FramelessWindowHint | Qt.Dialog) + + self.editor = parent + self.stack_history = stack_history + self.tabs = tabs + + self.setSelectionMode(QListWidget.SingleSelection) + self.itemActivated.connect(self.item_selected) + + self.id_list = [] + self.load_data() + size = CONF.get('main', 'completion/size') + self.resize(*size) + self.set_dialog_position() + self.setCurrentRow(0) + + CONF.config_shortcut(lambda: self.select_row(-1), context='Editor', + name='Go to previous file', parent=self) + CONF.config_shortcut(lambda: self.select_row(1), context='Editor', + name='Go to next file', parent=self) + + def load_data(self): + """Fill ListWidget with the tabs texts. + + Add elements in inverse order of stack_history. + """ + for index in reversed(self.stack_history): + text = self.tabs.tabText(index) + text = text.replace('&', '') + item = QListWidgetItem(ima.icon('TextFileIcon'), text) + self.addItem(item) + + def item_selected(self, item=None): + """Change to the selected document and hide this widget.""" + if item is None: + item = self.currentItem() + + # stack history is in inverse order + try: + index = self.stack_history[-(self.currentRow()+1)] + except IndexError: + pass + else: + self.editor.set_stack_index(index) + self.editor.current_changed(index) + self.hide() + + def select_row(self, steps): + """Move selected row a number of steps. + + Iterates in a cyclic behaviour. + """ + row = (self.currentRow() + steps) % self.count() + self.setCurrentRow(row) + + def set_dialog_position(self): + """Positions the tab switcher in the top-center of the editor.""" + left = int(self.editor.geometry().width()/2 - self.width()/2) + top = int(self.editor.tabs.tabBar().geometry().height() + + self.editor.fname_label.geometry().height()) + + self.move(self.editor.mapToGlobal(QPoint(left, top))) + + def keyReleaseEvent(self, event): + """Reimplement Qt method. + + Handle "most recent used" tab behavior, + When ctrl is released and tab_switcher is visible, tab will be changed. + """ + if self.isVisible(): + qsc = CONF.get_shortcut(context='Editor', name='Go to next file') + + for key in qsc.split('+'): + key = key.lower() + if ((key == 'ctrl' and event.key() == Qt.Key_Control) or + (key == 'alt' and event.key() == Qt.Key_Alt)): + self.item_selected() + event.accept() + + def keyPressEvent(self, event): + """Reimplement Qt method to allow cyclic behavior.""" + if event.key() == Qt.Key_Down: + self.select_row(1) + elif event.key() == Qt.Key_Up: + self.select_row(-1) + + def focusOutEvent(self, event): + """Reimplement Qt method to close the widget when loosing focus.""" + event.ignore() + if sys.platform == "darwin": + if event.reason() != Qt.ActiveWindowFocusReason: + self.close() + else: + self.close() + + +class EditorStack(QWidget): + reset_statusbar = Signal() + readonly_changed = Signal(bool) + encoding_changed = Signal(str) + sig_editor_cursor_position_changed = Signal(int, int) + sig_refresh_eol_chars = Signal(str) + sig_refresh_formatting = Signal(bool) + starting_long_process = Signal(str) + ending_long_process = Signal(str) + redirect_stdio = Signal(bool) + exec_in_extconsole = Signal(str, bool) + run_cell_in_ipyclient = Signal(str, object, str, bool, bool) + debug_cell_in_ipyclient = Signal(str, object, str, bool, bool) + update_plugin_title = Signal() + editor_focus_changed = Signal() + zoom_in = Signal() + zoom_out = Signal() + zoom_reset = Signal() + sig_open_file = Signal(dict) + sig_close_file = Signal(str, str) + file_saved = Signal(str, str, str) + file_renamed_in_data = Signal(str, str, str) + opened_files_list_changed = Signal() + active_languages_stats = Signal(set) + todo_results_changed = Signal() + update_code_analysis_actions = Signal() + refresh_file_dependent_actions = Signal() + refresh_save_all_action = Signal() + sig_breakpoints_saved = Signal() + text_changed_at = Signal(str, int) + current_file_changed = Signal(str, int, int, int) + plugin_load = Signal((str,), ()) + edit_goto = Signal(str, int, str) + sig_split_vertically = Signal() + sig_split_horizontally = Signal() + sig_new_file = Signal((str,), ()) + sig_save_as = Signal() + sig_prev_edit_pos = Signal() + sig_prev_cursor = Signal() + sig_next_cursor = Signal() + sig_prev_warning = Signal() + sig_next_warning = Signal() + sig_go_to_definition = Signal(str, int, int) + sig_perform_completion_request = Signal(str, str, dict) + sig_option_changed = Signal(str, object) # config option needs changing + sig_save_bookmark = Signal(int) + sig_load_bookmark = Signal(int) + sig_save_bookmarks = Signal(str, str) + + sig_help_requested = Signal(dict) + """ + This signal is emitted to request help on a given object `name`. + + Parameters + ---------- + help_data: dict + Dictionary required by the Help pane to render a docstring. + + Examples + -------- + >>> help_data = { + 'obj_text': str, + 'name': str, + 'argspec': str, + 'note': str, + 'docstring': str, + 'force_refresh': bool, + 'path': str, + } + + See Also + -------- + :py:meth:spyder.plugins.editor.widgets.editor.EditorStack.send_to_help + """ + + def __init__(self, parent, actions): + QWidget.__init__(self, parent) + + self.setAttribute(Qt.WA_DeleteOnClose) + + self.threadmanager = ThreadManager(self) + self.new_window = False + self.horsplit_action = None + self.versplit_action = None + self.close_action = None + self.__get_split_actions() + + layout = QVBoxLayout() + layout.setContentsMargins(0, 0, 0, 0) + self.setLayout(layout) + + self.menu = None + self.switcher_dlg = None + self.switcher_manager = None + self.tabs = None + self.tabs_switcher = None + + self.stack_history = StackHistory(self) + + # External panels + self.external_panels = [] + + self.setup_editorstack(parent, layout) + + self.find_widget = None + + self.data = [] + + switcher_action = create_action( + self, + _("File switcher..."), + icon=ima.icon('filelist'), + triggered=self.open_switcher_dlg) + symbolfinder_action = create_action( + self, + _("Find symbols in file..."), + icon=ima.icon('symbol_find'), + triggered=self.open_symbolfinder_dlg) + copy_to_cb_action = create_action(self, _("Copy path to clipboard"), + icon=ima.icon('editcopy'), + triggered=lambda: + QApplication.clipboard().setText(self.get_current_filename())) + close_right = create_action(self, _("Close all to the right"), + triggered=self.close_all_right) + close_all_but_this = create_action(self, _("Close all but this"), + triggered=self.close_all_but_this) + + sort_tabs = create_action(self, _("Sort tabs alphabetically"), + triggered=self.sort_file_tabs_alphabetically) + + if sys.platform == 'darwin': + text = _("Show in Finder") + else: + text = _("Show in external file explorer") + external_fileexp_action = create_action( + self, text, + triggered=self.show_in_external_file_explorer, + shortcut=CONF.get_shortcut(context="Editor", + name="show in external file explorer"), + context=Qt.WidgetShortcut) + + self.menu_actions = actions + [external_fileexp_action, + None, switcher_action, + symbolfinder_action, + copy_to_cb_action, None, close_right, + close_all_but_this, sort_tabs] + self.outlineexplorer = None + self.is_closable = False + self.new_action = None + self.open_action = None + self.save_action = None + self.revert_action = None + self.tempfile_path = None + self.title = _("Editor") + self.todolist_enabled = True + self.is_analysis_done = False + self.linenumbers_enabled = True + self.blanks_enabled = False + self.scrollpastend_enabled = False + self.edgeline_enabled = True + self.edgeline_columns = (79,) + self.close_parentheses_enabled = True + self.close_quotes_enabled = True + self.add_colons_enabled = True + self.auto_unindent_enabled = True + self.indent_chars = " "*4 + self.tab_stop_width_spaces = 4 + self.show_class_func_dropdown = False + self.help_enabled = False + self.default_font = None + self.wrap_enabled = False + self.tabmode_enabled = False + self.stripmode_enabled = False + self.intelligent_backspace_enabled = True + self.automatic_completions_enabled = True + self.automatic_completion_chars = 3 + self.automatic_completion_ms = 300 + self.completions_hint_enabled = True + self.completions_hint_after_ms = 500 + self.hover_hints_enabled = True + self.format_on_save = False + self.code_snippets_enabled = True + self.code_folding_enabled = True + self.underline_errors_enabled = False + self.highlight_current_line_enabled = False + self.highlight_current_cell_enabled = False + self.occurrence_highlighting_enabled = True + self.occurrence_highlighting_timeout = 1500 + self.checkeolchars_enabled = True + self.always_remove_trailing_spaces = False + self.add_newline = False + self.remove_trailing_newlines = False + self.convert_eol_on_save = False + self.convert_eol_on_save_to = 'LF' + self.focus_to_editor = True + self.run_cell_copy = False + self.create_new_file_if_empty = True + self.indent_guides = False + ccs = 'spyder/dark' + if ccs not in syntaxhighlighters.COLOR_SCHEME_NAMES: + ccs = syntaxhighlighters.COLOR_SCHEME_NAMES[0] + self.color_scheme = ccs + self.__file_status_flag = False + + # Real-time code analysis + self.analysis_timer = QTimer(self) + self.analysis_timer.setSingleShot(True) + self.analysis_timer.setInterval(1000) + self.analysis_timer.timeout.connect(self.analyze_script) + + # Update filename label + self.editor_focus_changed.connect(self.update_fname_label) + + # Accepting drops + self.setAcceptDrops(True) + + # Local shortcuts + self.shortcuts = self.create_shortcuts() + + # For opening last closed tabs + self.last_closed_files = [] + + # Reference to save msgbox and avoid memory to be freed. + self.msgbox = None + + # File types and filters used by the Save As dialog + self.edit_filetypes = None + self.edit_filters = None + + # For testing + self.save_dialog_on_tests = not running_under_pytest() + + # Autusave component + self.autosave = AutosaveForStack(self) + + self.last_cell_call = None + + @Slot() + def show_in_external_file_explorer(self, fnames=None): + """Show file in external file explorer""" + if fnames is None or isinstance(fnames, bool): + fnames = self.get_current_filename() + try: + show_in_external_file_explorer(fnames) + except FileNotFoundError as error: + file = str(error).split("'")[1] + if "xdg-open" in file: + msg_title = _("Warning") + msg = _("Spyder can't show this file in the external file " + "explorer because the xdg-utils package is " + "not available on your system.") + QMessageBox.information(self, msg_title, msg, + QMessageBox.Ok) + + def create_shortcuts(self): + """Create local shortcuts""" + # --- Configurable shortcuts + inspect = CONF.config_shortcut( + self.inspect_current_object, + context='Editor', + name='Inspect current object', + parent=self) + + set_breakpoint = CONF.config_shortcut( + self.set_or_clear_breakpoint, + context='Editor', + name='Breakpoint', + parent=self) + + set_cond_breakpoint = CONF.config_shortcut( + self.set_or_edit_conditional_breakpoint, + context='Editor', + name='Conditional breakpoint', + parent=self) + + gotoline = CONF.config_shortcut( + self.go_to_line, + context='Editor', + name='Go to line', + parent=self) + + tab = CONF.config_shortcut( + lambda: self.tab_navigation_mru(forward=False), + context='Editor', + name='Go to previous file', + parent=self) + + tabshift = CONF.config_shortcut( + self.tab_navigation_mru, + context='Editor', + name='Go to next file', + parent=self) + + prevtab = CONF.config_shortcut( + lambda: self.tabs.tab_navigate(-1), + context='Editor', + name='Cycle to previous file', + parent=self) + + nexttab = CONF.config_shortcut( + lambda: self.tabs.tab_navigate(1), + context='Editor', + name='Cycle to next file', + parent=self) + + run_selection = CONF.config_shortcut( + self.run_selection, + context='Editor', + name='Run selection', + parent=self) + + run_to_line = CONF.config_shortcut( + self.run_to_line, + context='Editor', + name='Run to line', + parent=self) + + run_from_line = CONF.config_shortcut( + self.run_from_line, + context='Editor', + name='Run from line', + parent=self) + + new_file = CONF.config_shortcut( + lambda: self.sig_new_file[()].emit(), + context='Editor', + name='New file', + parent=self) + + open_file = CONF.config_shortcut( + lambda: self.plugin_load[()].emit(), + context='Editor', + name='Open file', + parent=self) + + save_file = CONF.config_shortcut( + self.save, + context='Editor', + name='Save file', + parent=self) + + save_all = CONF.config_shortcut( + self.save_all, + context='Editor', + name='Save all', + parent=self) + + save_as = CONF.config_shortcut( + lambda: self.sig_save_as.emit(), + context='Editor', + name='Save As', + parent=self) + + close_all = CONF.config_shortcut( + self.close_all_files, + context='Editor', + name='Close all', + parent=self) + + prev_edit_pos = CONF.config_shortcut( + lambda: self.sig_prev_edit_pos.emit(), + context="Editor", + name="Last edit location", + parent=self) + + prev_cursor = CONF.config_shortcut( + lambda: self.sig_prev_cursor.emit(), + context="Editor", + name="Previous cursor position", + parent=self) + + next_cursor = CONF.config_shortcut( + lambda: self.sig_next_cursor.emit(), + context="Editor", + name="Next cursor position", + parent=self) + + zoom_in_1 = CONF.config_shortcut( + lambda: self.zoom_in.emit(), + context="Editor", + name="zoom in 1", + parent=self) + + zoom_in_2 = CONF.config_shortcut( + lambda: self.zoom_in.emit(), + context="Editor", + name="zoom in 2", + parent=self) + + zoom_out = CONF.config_shortcut( + lambda: self.zoom_out.emit(), + context="Editor", + name="zoom out", + parent=self) + + zoom_reset = CONF.config_shortcut( + lambda: self.zoom_reset.emit(), + context="Editor", + name="zoom reset", + parent=self) + + close_file_1 = CONF.config_shortcut( + self.close_file, + context="Editor", + name="close file 1", + parent=self) + + close_file_2 = CONF.config_shortcut( + self.close_file, + context="Editor", + name="close file 2", + parent=self) + + run_cell = CONF.config_shortcut( + self.run_cell, + context="Editor", + name="run cell", + parent=self) + + debug_cell = CONF.config_shortcut( + self.debug_cell, + context="Editor", + name="debug cell", + parent=self) + + run_cell_and_advance = CONF.config_shortcut( + self.run_cell_and_advance, + context="Editor", + name="run cell and advance", + parent=self) + + go_to_next_cell = CONF.config_shortcut( + self.advance_cell, + context="Editor", + name="go to next cell", + parent=self) + + go_to_previous_cell = CONF.config_shortcut( + lambda: self.advance_cell(reverse=True), + context="Editor", + name="go to previous cell", + parent=self) + + re_run_last_cell = CONF.config_shortcut( + self.re_run_last_cell, + context="Editor", + name="re-run last cell", + parent=self) + + prev_warning = CONF.config_shortcut( + lambda: self.sig_prev_warning.emit(), + context="Editor", + name="Previous warning", + parent=self) + + next_warning = CONF.config_shortcut( + lambda: self.sig_next_warning.emit(), + context="Editor", + name="Next warning", + parent=self) + + split_vertically = CONF.config_shortcut( + lambda: self.sig_split_vertically.emit(), + context="Editor", + name="split vertically", + parent=self) + + split_horizontally = CONF.config_shortcut( + lambda: self.sig_split_horizontally.emit(), + context="Editor", + name="split horizontally", + parent=self) + + close_split = CONF.config_shortcut( + self.close_split, + context="Editor", + name="close split panel", + parent=self) + + external_fileexp = CONF.config_shortcut( + self.show_in_external_file_explorer, + context="Editor", + name="show in external file explorer", + parent=self) + + # Return configurable ones + return [inspect, set_breakpoint, set_cond_breakpoint, gotoline, tab, + tabshift, run_selection, run_to_line, run_from_line, new_file, + open_file, save_file, save_all, save_as, close_all, + prev_edit_pos, prev_cursor, next_cursor, zoom_in_1, zoom_in_2, + zoom_out, zoom_reset, close_file_1, close_file_2, run_cell, + debug_cell, run_cell_and_advance, + go_to_next_cell, go_to_previous_cell, re_run_last_cell, + prev_warning, next_warning, split_vertically, + split_horizontally, close_split, + prevtab, nexttab, external_fileexp] + + def get_shortcut_data(self): + """ + Returns shortcut data, a list of tuples (shortcut, text, default) + shortcut (QShortcut or QAction instance) + text (string): action/shortcut description + default (string): default key sequence + """ + return [sc.data for sc in self.shortcuts] + + def setup_editorstack(self, parent, layout): + """Setup editorstack's layout""" + layout.setSpacing(0) + + # Create filename label, spinner and the toolbar that contains them + self.create_top_widgets() + + # Add top toolbar + layout.addWidget(self.top_toolbar) + + # Tabbar + menu_btn = create_toolbutton(self, icon=ima.icon('tooloptions'), + tip=_('Options')) + menu_btn.setStyleSheet(str(PANES_TABBAR_STYLESHEET)) + self.menu = QMenu(self) + menu_btn.setMenu(self.menu) + menu_btn.setPopupMode(menu_btn.InstantPopup) + self.menu.aboutToShow.connect(self.__setup_menu) + + corner_widgets = {Qt.TopRightCorner: [menu_btn]} + self.tabs = BaseTabs(self, menu=self.menu, menu_use_tooltips=True, + corner_widgets=corner_widgets) + self.tabs.set_close_function(self.close_file) + self.tabs.tabBar().tabMoved.connect(self.move_editorstack_data) + self.tabs.setMovable(True) + + self.stack_history.refresh() + + if hasattr(self.tabs, 'setDocumentMode') \ + and not sys.platform == 'darwin': + # Don't set document mode to true on OSX because it generates + # a crash when the editor is detached from the main window + # Fixes spyder-ide/spyder#561. + self.tabs.setDocumentMode(True) + self.tabs.currentChanged.connect(self.current_changed) + + tab_container = QWidget() + tab_container.setObjectName('tab-container') + tab_layout = QHBoxLayout(tab_container) + tab_layout.setContentsMargins(0, 0, 0, 0) + tab_layout.addWidget(self.tabs) + layout.addWidget(tab_container) + + # Show/hide icons in plugin menus for Mac + if sys.platform == 'darwin': + self.menu.aboutToHide.connect( + lambda menu=self.menu: + set_menu_icons(menu, False)) + + def create_top_widgets(self): + # Filename label + self.fname_label = QLabel() + + # Spacer + spacer = QWidget() + spacer.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Preferred) + + # Spinner + self.spinner = create_waitspinner(size=16, parent=self.fname_label) + + # Add widgets to toolbar + self.top_toolbar = QToolBar(self) + self.top_toolbar.addWidget(self.fname_label) + self.top_toolbar.addWidget(spacer) + self.top_toolbar.addWidget(self.spinner) + + # Set toolbar style + css = qstylizer.style.StyleSheet() + css.QToolBar.setValues( + margin='0px', + padding='4px', + borderBottom=f'1px solid {QStylePalette.COLOR_BACKGROUND_4}' + ) + self.top_toolbar.setStyleSheet(css.toString()) + + def hide_tooltip(self): + """Hide any open tooltips.""" + for finfo in self.data: + finfo.editor.hide_tooltip() + + @Slot() + def update_fname_label(self): + """Update file name label.""" + filename = to_text_string(self.get_current_filename()) + if len(filename) > 100: + shorten_filename = u'...' + filename[-100:] + else: + shorten_filename = filename + self.fname_label.setText(shorten_filename) + + def add_corner_widgets_to_tabbar(self, widgets): + self.tabs.add_corner_widgets(widgets) + + @Slot() + def close_split(self): + """Closes the editorstack if it is not the last one opened.""" + if self.is_closable: + self.close() + + def closeEvent(self, event): + """Overrides QWidget closeEvent().""" + self.threadmanager.close_all_threads() + self.analysis_timer.timeout.disconnect(self.analyze_script) + + # Remove editor references from the outline explorer settings + if self.outlineexplorer is not None: + for finfo in self.data: + self.outlineexplorer.remove_editor(finfo.editor.oe_proxy) + + for finfo in self.data: + if not finfo.editor.is_cloned: + finfo.editor.notify_close() + QWidget.closeEvent(self, event) + + def clone_editor_from(self, other_finfo, set_current): + fname = other_finfo.filename + enc = other_finfo.encoding + new = other_finfo.newly_created + finfo = self.create_new_editor(fname, enc, "", + set_current=set_current, new=new, + cloned_from=other_finfo.editor) + finfo.set_todo_results(other_finfo.todo_results) + return finfo.editor + + def clone_from(self, other): + """Clone EditorStack from other instance""" + for other_finfo in other.data: + self.clone_editor_from(other_finfo, set_current=True) + self.set_stack_index(other.get_stack_index()) + + @Slot() + @Slot(str) + def open_switcher_dlg(self, initial_text=''): + """Open file list management dialog box""" + if not self.tabs.count(): + return + if self.switcher_dlg is not None and self.switcher_dlg.isVisible(): + self.switcher_dlg.hide() + self.switcher_dlg.clear() + return + if self.switcher_dlg is None: + from spyder.widgets.switcher import Switcher + self.switcher_dlg = Switcher(self) + self.switcher_manager = EditorSwitcherManager( + self.get_plugin(), + self.switcher_dlg, + lambda: self.get_current_editor(), + lambda: self, + section=self.get_plugin_title()) + + if isinstance(initial_text, bool): + initial_text = '' + + self.switcher_dlg.set_search_text(initial_text) + self.switcher_dlg.setup() + self.switcher_dlg.show() + # Note: the +1 pixel on the top makes it look better + delta_top = (self.tabs.tabBar().geometry().height() + + self.fname_label.geometry().height() + 1) + self.switcher_dlg.set_position(delta_top) + + @Slot() + def open_symbolfinder_dlg(self): + self.open_switcher_dlg(initial_text='@') + + def get_plugin(self): + """Get the plugin of the parent widget.""" + # Needed for the editor stack to use its own switcher instance. + # See spyder-ide/spyder#10684. + return self.parent().plugin + + def get_plugin_title(self): + """Get the plugin title of the parent widget.""" + # Needed for the editor stack to use its own switcher instance. + # See spyder-ide/spyder#9469. + return self.get_plugin().get_plugin_title() + + def go_to_line(self, line=None): + """Go to line dialog""" + if line is not None: + # When this method is called from the flileswitcher, a line + # number is specified, so there is no need for the dialog. + self.get_current_editor().go_to_line(line) + else: + if self.data: + self.get_current_editor().exec_gotolinedialog() + + def set_or_clear_breakpoint(self): + """Set/clear breakpoint""" + if self.data: + editor = self.get_current_editor() + editor.debugger.toogle_breakpoint() + + def set_or_edit_conditional_breakpoint(self): + """Set conditional breakpoint""" + if self.data: + editor = self.get_current_editor() + editor.debugger.toogle_breakpoint(edit_condition=True) + + def set_bookmark(self, slot_num): + """Bookmark current position to given slot.""" + if self.data: + editor = self.get_current_editor() + editor.add_bookmark(slot_num) + + def inspect_current_object(self, pos=None): + """Inspect current object in the Help plugin""" + editor = self.get_current_editor() + editor.sig_display_object_info.connect(self.display_help) + cursor = None + offset = editor.get_position('cursor') + if pos: + cursor = editor.get_last_hover_cursor() + if cursor: + offset = cursor.position() + else: + return + + line, col = editor.get_cursor_line_column(cursor) + editor.request_hover(line, col, offset, + show_hint=False, clicked=bool(pos)) + + @Slot(str, bool) + def display_help(self, help_text, clicked): + editor = self.get_current_editor() + if clicked: + name = editor.get_last_hover_word() + else: + name = editor.get_current_word(help_req=True) + + try: + editor.sig_display_object_info.disconnect(self.display_help) + except TypeError: + # Needed to prevent an error after some time in idle. + # See spyder-ide/spyder#11228 + pass + + self.send_to_help(name, help_text, force=True) + + # ------ Editor Widget Settings + def set_closable(self, state): + """Parent widget must handle the closable state""" + self.is_closable = state + + def set_io_actions(self, new_action, open_action, + save_action, revert_action): + self.new_action = new_action + self.open_action = open_action + self.save_action = save_action + self.revert_action = revert_action + + def set_find_widget(self, find_widget): + self.find_widget = find_widget + + def set_outlineexplorer(self, outlineexplorer): + self.outlineexplorer = outlineexplorer + + def add_outlineexplorer_button(self, editor_plugin): + oe_btn = create_toolbutton(editor_plugin) + oe_btn.setDefaultAction(self.outlineexplorer.visibility_action) + self.add_corner_widgets_to_tabbar([5, oe_btn]) + + def set_tempfile_path(self, path): + self.tempfile_path = path + + def set_title(self, text): + self.title = text + + def set_classfunc_dropdown_visible(self, state): + self.show_class_func_dropdown = state + if self.data: + for finfo in self.data: + if finfo.editor.is_python_like(): + finfo.editor.classfuncdropdown.setVisible(state) + + def __update_editor_margins(self, editor): + editor.linenumberarea.setup_margins( + linenumbers=self.linenumbers_enabled, markers=self.has_markers()) + + def has_markers(self): + """Return True if this editorstack has a marker margin for TODOs or + code analysis""" + return self.todolist_enabled + + def set_todolist_enabled(self, state, current_finfo=None): + # CONF.get(self.CONF_SECTION, 'todo_list') + self.todolist_enabled = state + if self.data: + for finfo in self.data: + self.__update_editor_margins(finfo.editor) + finfo.cleanup_todo_results() + if state and current_finfo is not None: + if current_finfo is not finfo: + finfo.run_todo_finder() + + def set_linenumbers_enabled(self, state, current_finfo=None): + # CONF.get(self.CONF_SECTION, 'line_numbers') + self.linenumbers_enabled = state + if self.data: + for finfo in self.data: + self.__update_editor_margins(finfo.editor) + + def set_blanks_enabled(self, state): + self.blanks_enabled = state + if self.data: + for finfo in self.data: + finfo.editor.set_blanks_enabled(state) + + def set_scrollpastend_enabled(self, state): + self.scrollpastend_enabled = state + if self.data: + for finfo in self.data: + finfo.editor.set_scrollpastend_enabled(state) + + def set_edgeline_enabled(self, state): + # CONF.get(self.CONF_SECTION, 'edge_line') + self.edgeline_enabled = state + if self.data: + for finfo in self.data: + finfo.editor.edge_line.set_enabled(state) + + def set_edgeline_columns(self, columns): + # CONF.get(self.CONF_SECTION, 'edge_line_column') + self.edgeline_columns = columns + if self.data: + for finfo in self.data: + finfo.editor.edge_line.set_columns(columns) + + def set_indent_guides(self, state): + self.indent_guides = state + if self.data: + for finfo in self.data: + finfo.editor.toggle_identation_guides(state) + + def set_close_parentheses_enabled(self, state): + # CONF.get(self.CONF_SECTION, 'close_parentheses') + self.close_parentheses_enabled = state + if self.data: + for finfo in self.data: + finfo.editor.set_close_parentheses_enabled(state) + + def set_close_quotes_enabled(self, state): + # CONF.get(self.CONF_SECTION, 'close_quotes') + self.close_quotes_enabled = state + if self.data: + for finfo in self.data: + finfo.editor.set_close_quotes_enabled(state) + + def set_add_colons_enabled(self, state): + # CONF.get(self.CONF_SECTION, 'add_colons') + self.add_colons_enabled = state + if self.data: + for finfo in self.data: + finfo.editor.set_add_colons_enabled(state) + + def set_auto_unindent_enabled(self, state): + # CONF.get(self.CONF_SECTION, 'auto_unindent') + self.auto_unindent_enabled = state + if self.data: + for finfo in self.data: + finfo.editor.set_auto_unindent_enabled(state) + + def set_indent_chars(self, indent_chars): + # CONF.get(self.CONF_SECTION, 'indent_chars') + indent_chars = indent_chars[1:-1] # removing the leading/ending '*' + self.indent_chars = indent_chars + if self.data: + for finfo in self.data: + finfo.editor.set_indent_chars(indent_chars) + + def set_tab_stop_width_spaces(self, tab_stop_width_spaces): + # CONF.get(self.CONF_SECTION, 'tab_stop_width') + self.tab_stop_width_spaces = tab_stop_width_spaces + if self.data: + for finfo in self.data: + finfo.editor.tab_stop_width_spaces = tab_stop_width_spaces + finfo.editor.update_tab_stop_width_spaces() + + def set_help_enabled(self, state): + self.help_enabled = state + + def set_default_font(self, font, color_scheme=None): + self.default_font = font + if color_scheme is not None: + self.color_scheme = color_scheme + if self.data: + for finfo in self.data: + finfo.editor.set_font(font, color_scheme) + + def set_color_scheme(self, color_scheme): + self.color_scheme = color_scheme + if self.data: + for finfo in self.data: + finfo.editor.set_color_scheme(color_scheme) + + def set_wrap_enabled(self, state): + # CONF.get(self.CONF_SECTION, 'wrap') + self.wrap_enabled = state + if self.data: + for finfo in self.data: + finfo.editor.toggle_wrap_mode(state) + + def set_tabmode_enabled(self, state): + # CONF.get(self.CONF_SECTION, 'tab_always_indent') + self.tabmode_enabled = state + if self.data: + for finfo in self.data: + finfo.editor.set_tab_mode(state) + + def set_stripmode_enabled(self, state): + # CONF.get(self.CONF_SECTION, 'strip_trailing_spaces_on_modify') + self.stripmode_enabled = state + if self.data: + for finfo in self.data: + finfo.editor.set_strip_mode(state) + + def set_intelligent_backspace_enabled(self, state): + # CONF.get(self.CONF_SECTION, 'intelligent_backspace') + self.intelligent_backspace_enabled = state + if self.data: + for finfo in self.data: + finfo.editor.toggle_intelligent_backspace(state) + + def set_code_snippets_enabled(self, state): + self.code_snippets_enabled = state + if self.data: + for finfo in self.data: + finfo.editor.toggle_code_snippets(state) + + def set_code_folding_enabled(self, state): + self.code_folding_enabled = state + if self.data: + for finfo in self.data: + finfo.editor.toggle_code_folding(state) + + def set_automatic_completions_enabled(self, state): + self.automatic_completions_enabled = state + if self.data: + for finfo in self.data: + finfo.editor.toggle_automatic_completions(state) + + def set_automatic_completions_after_chars(self, chars): + self.automatic_completion_chars = chars + if self.data: + for finfo in self.data: + finfo.editor.set_automatic_completions_after_chars(chars) + + def set_automatic_completions_after_ms(self, ms): + self.automatic_completion_ms = ms + if self.data: + for finfo in self.data: + finfo.editor.set_automatic_completions_after_ms(ms) + + def set_completions_hint_enabled(self, state): + self.completions_hint_enabled = state + if self.data: + for finfo in self.data: + finfo.editor.toggle_completions_hint(state) + + def set_completions_hint_after_ms(self, ms): + self.completions_hint_after_ms = ms + if self.data: + for finfo in self.data: + finfo.editor.set_completions_hint_after_ms(ms) + + def set_hover_hints_enabled(self, state): + self.hover_hints_enabled = state + if self.data: + for finfo in self.data: + finfo.editor.toggle_hover_hints(state) + + def set_format_on_save(self, state): + self.format_on_save = state + if self.data: + for finfo in self.data: + finfo.editor.toggle_format_on_save(state) + + def set_occurrence_highlighting_enabled(self, state): + # CONF.get(self.CONF_SECTION, 'occurrence_highlighting') + self.occurrence_highlighting_enabled = state + if self.data: + for finfo in self.data: + finfo.editor.set_occurrence_highlighting(state) + + def set_occurrence_highlighting_timeout(self, timeout): + # CONF.get(self.CONF_SECTION, 'occurrence_highlighting/timeout') + self.occurrence_highlighting_timeout = timeout + if self.data: + for finfo in self.data: + finfo.editor.set_occurrence_timeout(timeout) + + def set_underline_errors_enabled(self, state): + self.underline_errors_enabled = state + if self.data: + for finfo in self.data: + finfo.editor.set_underline_errors_enabled(state) + + def set_highlight_current_line_enabled(self, state): + self.highlight_current_line_enabled = state + if self.data: + for finfo in self.data: + finfo.editor.set_highlight_current_line(state) + + def set_highlight_current_cell_enabled(self, state): + self.highlight_current_cell_enabled = state + if self.data: + for finfo in self.data: + finfo.editor.set_highlight_current_cell(state) + + def set_checkeolchars_enabled(self, state): + # CONF.get(self.CONF_SECTION, 'check_eol_chars') + self.checkeolchars_enabled = state + + def set_always_remove_trailing_spaces(self, state): + # CONF.get(self.CONF_SECTION, 'always_remove_trailing_spaces') + self.always_remove_trailing_spaces = state + if self.data: + for finfo in self.data: + finfo.editor.set_remove_trailing_spaces(state) + + def set_add_newline(self, state): + self.add_newline = state + if self.data: + for finfo in self.data: + finfo.editor.set_add_newline(state) + + def set_remove_trailing_newlines(self, state): + self.remove_trailing_newlines = state + if self.data: + for finfo in self.data: + finfo.editor.set_remove_trailing_newlines(state) + + def set_convert_eol_on_save(self, state): + """If `state` is `True`, saving files will convert line endings.""" + # CONF.get(self.CONF_SECTION, 'convert_eol_on_save') + self.convert_eol_on_save = state + + def set_convert_eol_on_save_to(self, state): + """`state` can be one of ('LF', 'CRLF', 'CR')""" + # CONF.get(self.CONF_SECTION, 'convert_eol_on_save_to') + self.convert_eol_on_save_to = state + + def set_focus_to_editor(self, state): + self.focus_to_editor = state + + def set_run_cell_copy(self, state): + """If `state` is ``True``, code cells will be copied to the console.""" + self.run_cell_copy = state + + def set_current_project_path(self, root_path=None): + """ + Set the current active project root path. + + Parameters + ---------- + root_path: str or None, optional + Path to current project root path. Default is None. + """ + for finfo in self.data: + finfo.editor.set_current_project_path(root_path) + + # ------ Stacked widget management + def get_stack_index(self): + return self.tabs.currentIndex() + + def get_current_finfo(self): + if self.data: + return self.data[self.get_stack_index()] + + def get_current_editor(self): + return self.tabs.currentWidget() + + def get_stack_count(self): + return self.tabs.count() + + def set_stack_index(self, index, instance=None): + if instance == self or instance == None: + self.tabs.setCurrentIndex(index) + + def set_tabbar_visible(self, state): + self.tabs.tabBar().setVisible(state) + + def remove_from_data(self, index): + self.tabs.blockSignals(True) + self.tabs.removeTab(index) + self.data.pop(index) + self.tabs.blockSignals(False) + self.update_actions() + + def __modified_readonly_title(self, title, is_modified, is_readonly): + if is_modified is not None and is_modified: + title += "*" + if is_readonly is not None and is_readonly: + title = "(%s)" % title + return title + + def get_tab_text(self, index, is_modified=None, is_readonly=None): + """Return tab title.""" + files_path_list = [finfo.filename for finfo in self.data] + fname = self.data[index].filename + fname = sourcecode.disambiguate_fname(files_path_list, fname) + return self.__modified_readonly_title(fname, + is_modified, is_readonly) + + def get_tab_tip(self, filename, is_modified=None, is_readonly=None): + """Return tab menu title""" + text = u"%s — %s" + text = self.__modified_readonly_title(text, + is_modified, is_readonly) + if self.tempfile_path is not None\ + and filename == encoding.to_unicode_from_fs(self.tempfile_path): + temp_file_str = to_text_string(_("Temporary file")) + return text % (temp_file_str, self.tempfile_path) + else: + return text % (osp.basename(filename), osp.dirname(filename)) + + def add_to_data(self, finfo, set_current, add_where='end'): + finfo.editor.oe_proxy = None + index = 0 if add_where == 'start' else len(self.data) + self.data.insert(index, finfo) + index = self.data.index(finfo) + editor = finfo.editor + self.tabs.insertTab(index, editor, self.get_tab_text(index)) + self.set_stack_title(index, False) + if set_current: + self.set_stack_index(index) + self.current_changed(index) + self.update_actions() + + def __repopulate_stack(self): + self.tabs.blockSignals(True) + self.tabs.clear() + for finfo in self.data: + if finfo.newly_created: + is_modified = True + else: + is_modified = None + index = self.data.index(finfo) + tab_text = self.get_tab_text(index, is_modified) + tab_tip = self.get_tab_tip(finfo.filename) + index = self.tabs.addTab(finfo.editor, tab_text) + self.tabs.setTabToolTip(index, tab_tip) + self.tabs.blockSignals(False) + + def rename_in_data(self, original_filename, new_filename): + index = self.has_filename(original_filename) + if index is None: + return + finfo = self.data[index] + + # Send close request to LSP + finfo.editor.notify_close() + + # Set new filename + finfo.filename = new_filename + finfo.editor.filename = new_filename + + # File type has changed! + original_ext = osp.splitext(original_filename)[1] + new_ext = osp.splitext(new_filename)[1] + if original_ext != new_ext: + # Set file language and re-run highlighter + txt = to_text_string(finfo.editor.get_text_with_eol()) + language = get_file_language(new_filename, txt) + finfo.editor.set_language(language, new_filename) + finfo.editor.run_pygments_highlighter() + + # If the user renamed the file to a different language, we + # need to emit sig_open_file to see if we can start a + # language server for it. + options = { + 'language': language, + 'filename': new_filename, + 'codeeditor': finfo.editor + } + self.sig_open_file.emit(options) + + # Update panels + finfo.editor.set_debug_panel( + show_debug_panel=True, language=language) + finfo.editor.cleanup_code_analysis() + finfo.editor.cleanup_folding() + else: + # If there's no language change, we simply need to request a + # document_did_open for the new file. + finfo.editor.document_did_open() + + set_new_index = index == self.get_stack_index() + current_fname = self.get_current_filename() + finfo.editor.filename = new_filename + new_index = self.data.index(finfo) + self.__repopulate_stack() + if set_new_index: + self.set_stack_index(new_index) + else: + # Fixes spyder-ide/spyder#1287. + self.set_current_filename(current_fname) + if self.outlineexplorer is not None: + self.outlineexplorer.file_renamed( + finfo.editor.oe_proxy, finfo.filename) + return new_index + + def set_stack_title(self, index, is_modified): + finfo = self.data[index] + fname = finfo.filename + is_modified = (is_modified or finfo.newly_created) and not finfo.default + is_readonly = finfo.editor.isReadOnly() + tab_text = self.get_tab_text(index, is_modified, is_readonly) + tab_tip = self.get_tab_tip(fname, is_modified, is_readonly) + + # Only update tab text if have changed, otherwise an unwanted scrolling + # will happen when changing tabs. See spyder-ide/spyder#1170. + if tab_text != self.tabs.tabText(index): + self.tabs.setTabText(index, tab_text) + self.tabs.setTabToolTip(index, tab_tip) + + # ------ Context menu + def __setup_menu(self): + """Setup tab context menu before showing it""" + self.menu.clear() + if self.data: + actions = self.menu_actions + else: + actions = (self.new_action, self.open_action) + self.setFocus() # --> Editor.__get_focus_editortabwidget + add_actions(self.menu, list(actions) + self.__get_split_actions()) + self.close_action.setEnabled(self.is_closable) + + if sys.platform == 'darwin': + set_menu_icons(self.menu, True) + + # ------ Hor/Ver splitting + def __get_split_actions(self): + if self.parent() is not None: + plugin = self.parent().plugin + else: + plugin = None + + # New window + if plugin is not None: + self.new_window_action = create_action( + self, _("New window"), + icon=ima.icon('newwindow'), + tip=_("Create a new editor window"), + triggered=plugin.create_new_window) + + # Splitting + self.versplit_action = create_action( + self, + _("Split vertically"), + icon=ima.icon('versplit'), + tip=_("Split vertically this editor window"), + triggered=lambda: self.sig_split_vertically.emit(), + shortcut=CONF.get_shortcut(context='Editor', + name='split vertically'), + context=Qt.WidgetShortcut) + + self.horsplit_action = create_action( + self, + _("Split horizontally"), + icon=ima.icon('horsplit'), + tip=_("Split horizontally this editor window"), + triggered=lambda: self.sig_split_horizontally.emit(), + shortcut=CONF.get_shortcut(context='Editor', + name='split horizontally'), + context=Qt.WidgetShortcut) + + self.close_action = create_action( + self, + _("Close this panel"), + icon=ima.icon('close_panel'), + triggered=self.close_split, + shortcut=CONF.get_shortcut(context='Editor', + name='close split panel'), + context=Qt.WidgetShortcut) + + # Regular actions + actions = [MENU_SEPARATOR, self.versplit_action, + self.horsplit_action, self.close_action] + + if self.new_window: + window = self.window() + close_window_action = create_action( + self, _("Close window"), + icon=ima.icon('close_pane'), + triggered=window.close) + actions += [MENU_SEPARATOR, self.new_window_action, + close_window_action] + elif plugin is not None: + if plugin._undocked_window is not None: + actions += [MENU_SEPARATOR, plugin._dock_action] + else: + actions += [MENU_SEPARATOR, self.new_window_action, + plugin._lock_unlock_action, + plugin._undock_action, + plugin._close_plugin_action] + + return actions + + def reset_orientation(self): + self.horsplit_action.setEnabled(True) + self.versplit_action.setEnabled(True) + + def set_orientation(self, orientation): + self.horsplit_action.setEnabled(orientation == Qt.Horizontal) + self.versplit_action.setEnabled(orientation == Qt.Vertical) + + def update_actions(self): + state = self.get_stack_count() > 0 + self.horsplit_action.setEnabled(state) + self.versplit_action.setEnabled(state) + + # ------ Accessors + def get_current_filename(self): + if self.data: + return self.data[self.get_stack_index()].filename + + def get_current_language(self): + if self.data: + return self.data[self.get_stack_index()].editor.language + + def get_filenames(self): + """ + Return a list with the names of all the files currently opened in + the editorstack. + """ + return [finfo.filename for finfo in self.data] + + def has_filename(self, filename): + """Return the self.data index position for the filename. + + Args: + filename: Name of the file to search for in self.data. + + Returns: + The self.data index for the filename. Returns None + if the filename is not found in self.data. + """ + data_filenames = self.get_filenames() + try: + # Try finding without calling the slow realpath + return data_filenames.index(filename) + except ValueError: + # See note about OSError on set_current_filename + # Fixes spyder-ide/spyder#17685 + try: + filename = fixpath(filename) + except OSError: + return None + + for index, editor_filename in enumerate(data_filenames): + if filename == fixpath(editor_filename): + return index + return None + + def set_current_filename(self, filename, focus=True): + """Set current filename and return the associated editor instance.""" + # FileNotFoundError: This is necessary to catch an error on Windows + # for files in a directory junction pointing to a symlink whose target + # is on a network drive that is unavailable at startup. + # Fixes spyder-ide/spyder#15714 + # OSError: This is necessary to catch an error on Windows when Spyder + # was closed with a file in a shared folder on a different computer on + # the network, and is started again when that folder is not available. + # Fixes spyder-ide/spyder#17685 + try: + index = self.has_filename(filename) + except (FileNotFoundError, OSError): + index = None + + if index is not None: + if focus: + self.set_stack_index(index) + editor = self.data[index].editor + if focus: + editor.setFocus() + else: + self.stack_history.remove_and_append(index) + + return editor + + def is_file_opened(self, filename=None): + """Return if filename is in the editor stack. + + Args: + filename: Name of the file to search for. If filename is None, + then checks if any file is open. + + Returns: + True: If filename is None and a file is open. + False: If filename is None and no files are open. + None: If filename is not None and the file isn't found. + integer: Index of file name in editor stack. + """ + if filename is None: + # Is there any file opened? + return len(self.data) > 0 + else: + return self.has_filename(filename) + + def get_index_from_filename(self, filename): + """ + Return the position index of a file in the tab bar of the editorstack + from its name. + """ + filenames = [d.filename for d in self.data] + return filenames.index(filename) + + @Slot(int, int) + def move_editorstack_data(self, start, end): + """Reorder editorstack.data so it is synchronized with the tab bar when + tabs are moved.""" + if start < 0 or end < 0: + return + else: + steps = abs(end - start) + direction = (end-start) // steps # +1 for right, -1 for left + + data = self.data + self.blockSignals(True) + + for i in range(start, end, direction): + data[i], data[i+direction] = data[i+direction], data[i] + + self.blockSignals(False) + self.refresh() + + # ------ Close file, tabwidget... + def close_file(self, index=None, force=False): + """Close file (index=None -> close current file) + Keep current file index unchanged (if current file + that is being closed)""" + current_index = self.get_stack_index() + count = self.get_stack_count() + + if index is None: + if count > 0: + index = current_index + else: + self.find_widget.set_editor(None) + return + + new_index = None + if count > 1: + if current_index == index: + new_index = self._get_previous_file_index() + else: + new_index = current_index + + can_close_file = self.parent().plugin.can_close_file( + self.data[index].filename) if self.parent() else True + is_ok = (force or self.save_if_changed(cancelable=True, index=index) + and can_close_file) + if is_ok: + finfo = self.data[index] + self.threadmanager.close_threads(finfo) + # Removing editor reference from outline explorer settings: + if self.outlineexplorer is not None: + self.outlineexplorer.remove_editor(finfo.editor.oe_proxy) + + filename = self.data[index].filename + self.remove_from_data(index) + finfo.editor.notify_close() + + # We pass self object ID as a QString, because otherwise it would + # depend on the platform: long for 64bit, int for 32bit. Replacing + # by long all the time is not working on some 32bit platforms. + # See spyder-ide/spyder#1094 and spyder-ide/spyder#1098. + self.sig_close_file.emit(str(id(self)), filename) + + self.opened_files_list_changed.emit() + self.update_code_analysis_actions.emit() + self.refresh_file_dependent_actions.emit() + self.update_plugin_title.emit() + + editor = self.get_current_editor() + if editor: + editor.setFocus() + + if new_index is not None: + if index < new_index: + new_index -= 1 + self.set_stack_index(new_index) + + self.add_last_closed_file(finfo.filename) + + if finfo.filename in self.autosave.file_hashes: + del self.autosave.file_hashes[finfo.filename] + + if self.get_stack_count() == 0 and self.create_new_file_if_empty: + self.sig_new_file[()].emit() + self.update_fname_label() + return False + self.__modify_stack_title() + return is_ok + + def register_completion_capabilities(self, capabilities, language): + """ + Register completion server capabilities across all editors. + + Parameters + ---------- + capabilities: dict + Capabilities supported by a language server. + language: str + Programming language for the language server (it has to be + in small caps). + """ + for index in range(self.get_stack_count()): + editor = self.tabs.widget(index) + if editor.language.lower() == language: + editor.register_completion_capabilities(capabilities) + + def start_completion_services(self, language): + """Notify language server availability to code editors.""" + for index in range(self.get_stack_count()): + editor = self.tabs.widget(index) + if editor.language.lower() == language: + editor.start_completion_services() + + def stop_completion_services(self, language): + """Notify language server unavailability to code editors.""" + for index in range(self.get_stack_count()): + editor = self.tabs.widget(index) + if editor.language.lower() == language: + editor.stop_completion_services() + + def close_all_files(self): + """Close all opened scripts""" + while self.close_file(): + pass + + def close_all_right(self): + """ Close all files opened to the right """ + num = self.get_stack_index() + n = self.get_stack_count() + for __ in range(num, n-1): + self.close_file(num+1) + + def close_all_but_this(self): + """Close all files but the current one""" + self.close_all_right() + for __ in range(0, self.get_stack_count() - 1): + self.close_file(0) + + def sort_file_tabs_alphabetically(self): + """Sort open tabs alphabetically.""" + while self.sorted() is False: + for i in range(0, self.tabs.tabBar().count()): + if(self.tabs.tabBar().tabText(i) > + self.tabs.tabBar().tabText(i + 1)): + self.tabs.tabBar().moveTab(i, i + 1) + + def sorted(self): + """Utility function for sort_file_tabs_alphabetically().""" + for i in range(0, self.tabs.tabBar().count() - 1): + if (self.tabs.tabBar().tabText(i) > + self.tabs.tabBar().tabText(i + 1)): + return False + return True + + def add_last_closed_file(self, fname): + """Add to last closed file list.""" + if fname in self.last_closed_files: + self.last_closed_files.remove(fname) + self.last_closed_files.insert(0, fname) + if len(self.last_closed_files) > 10: + self.last_closed_files.pop(-1) + + def get_last_closed_files(self): + return self.last_closed_files + + def set_last_closed_files(self, fnames): + self.last_closed_files = fnames + + # ------ Save + def save_if_changed(self, cancelable=False, index=None): + """Ask user to save file if modified. + + Args: + cancelable: Show Cancel button. + index: File to check for modification. + + Returns: + False when save() fails or is cancelled. + True when save() is successful, there are no modifications, + or user selects No or NoToAll. + + This function controls the message box prompt for saving + changed files. The actual save is performed in save() for + each index processed. This function also removes autosave files + corresponding to files the user chooses not to save. + """ + if index is None: + indexes = list(range(self.get_stack_count())) + else: + indexes = [index] + buttons = QMessageBox.Yes | QMessageBox.No + if cancelable: + buttons |= QMessageBox.Cancel + unsaved_nb = 0 + for index in indexes: + if self.data[index].editor.document().isModified(): + unsaved_nb += 1 + if not unsaved_nb: + # No file to save + return True + if unsaved_nb > 1: + buttons |= int(QMessageBox.YesToAll | QMessageBox.NoToAll) + yes_all = no_all = False + for index in indexes: + self.set_stack_index(index) + finfo = self.data[index] + if finfo.filename == self.tempfile_path or yes_all: + if not self.save(index): + return False + elif no_all: + self.autosave.remove_autosave_file(finfo) + elif (finfo.editor.document().isModified() and + self.save_dialog_on_tests): + + self.msgbox = QMessageBox( + QMessageBox.Question, + self.title, + _("%s has been modified." + "
Do you want to save changes?" + ) % osp.basename(finfo.filename), + buttons, + parent=self) + + answer = self.msgbox.exec_() + if answer == QMessageBox.Yes: + if not self.save(index): + return False + elif answer == QMessageBox.No: + self.autosave.remove_autosave_file(finfo.filename) + elif answer == QMessageBox.YesToAll: + if not self.save(index): + return False + yes_all = True + elif answer == QMessageBox.NoToAll: + self.autosave.remove_autosave_file(finfo.filename) + no_all = True + elif answer == QMessageBox.Cancel: + return False + return True + + def compute_hash(self, fileinfo): + """Compute hash of contents of editor. + + Args: + fileinfo: FileInfo object associated to editor whose hash needs + to be computed. + + Returns: + int: computed hash. + """ + txt = to_text_string(fileinfo.editor.get_text_with_eol()) + return hash(txt) + + def _write_to_file(self, fileinfo, filename): + """Low-level function for writing text of editor to file. + + Args: + fileinfo: FileInfo object associated to editor to be saved + filename: str with filename to save to + + This is a low-level function that only saves the text to file in the + correct encoding without doing any error handling. + """ + txt = to_text_string(fileinfo.editor.get_text_with_eol()) + fileinfo.encoding = encoding.write(txt, filename, fileinfo.encoding) + + def save(self, index=None, force=False, save_new_files=True): + """Write text of editor to a file. + + Args: + index: self.data index to save. If None, defaults to + currentIndex(). + force: Force save regardless of file state. + + Returns: + True upon successful save or when file doesn't need to be saved. + False if save failed. + + If the text isn't modified and it's not newly created, then the save + is aborted. If the file hasn't been saved before, then save_as() + is invoked. Otherwise, the file is written using the file name + currently in self.data. This function doesn't change the file name. + """ + if index is None: + # Save the currently edited file + if not self.get_stack_count(): + return + index = self.get_stack_index() + + finfo = self.data[index] + if not (finfo.editor.document().isModified() or + finfo.newly_created) and not force: + return True + if not osp.isfile(finfo.filename) and not force: + # File has not been saved yet + if save_new_files: + return self.save_as(index=index) + # The file doesn't need to be saved + return True + + # The following options (`always_remove_trailing_spaces`, + # `remove_trailing_newlines` and `add_newline`) also depend on the + # `format_on_save` value. + # See spyder-ide/spyder#17716 + if self.always_remove_trailing_spaces and not self.format_on_save: + self.remove_trailing_spaces(index) + if self.remove_trailing_newlines and not self.format_on_save: + self.trim_trailing_newlines(index) + if self.add_newline and not self.format_on_save: + self.add_newline_to_file(index) + + if self.convert_eol_on_save: + # hack to account for the fact that the config file saves + # CR/LF/CRLF while set_os_eol_chars wants the os.name value. + osname_lookup = {'LF': 'posix', 'CRLF': 'nt', 'CR': 'mac'} + osname = osname_lookup[self.convert_eol_on_save_to] + self.set_os_eol_chars(osname=osname) + + try: + if self.format_on_save and finfo.editor.formatting_enabled: + # Wait for document autoformat and then save + + # Waiting for the autoformat to complete is needed + # when the file is going to be closed after saving. + # See spyder-ide/spyder#17836 + format_eventloop = finfo.editor.format_eventloop + format_timer = finfo.editor.format_timer + format_timer.setSingleShot(True) + format_timer.timeout.connect(format_eventloop.quit) + + finfo.editor.sig_stop_operation_in_progress.connect( + lambda: self._save_file(finfo)) + finfo.editor.sig_stop_operation_in_progress.connect( + format_timer.stop) + finfo.editor.sig_stop_operation_in_progress.connect( + format_eventloop.quit) + + format_timer.start(10000) + finfo.editor.format_document() + format_eventloop.exec_() + else: + self._save_file(finfo) + return True + except EnvironmentError as error: + self.msgbox = QMessageBox( + QMessageBox.Critical, + _("Save Error"), + _("Unable to save file '%s'" + "

Error message:
%s" + ) % (osp.basename(finfo.filename), + str(error)), + parent=self) + self.msgbox.exec_() + return False + + def _save_file(self, finfo): + index = self.data.index(finfo) + self._write_to_file(finfo, finfo.filename) + file_hash = self.compute_hash(finfo) + self.autosave.file_hashes[finfo.filename] = file_hash + self.autosave.remove_autosave_file(finfo.filename) + finfo.newly_created = False + self.encoding_changed.emit(finfo.encoding) + finfo.lastmodified = QFileInfo(finfo.filename).lastModified() + + # We pass self object ID as a QString, because otherwise it would + # depend on the platform: long for 64bit, int for 32bit. Replacing + # by long all the time is not working on some 32bit platforms. + # See spyder-ide/spyder#1094 and spyder-ide/spyder#1098. + # The filename is passed instead of an index in case the tabs + # have been rearranged. See spyder-ide/spyder#5703. + self.file_saved.emit(str(id(self)), + finfo.filename, finfo.filename) + + finfo.editor.document().setModified(False) + self.modification_changed(index=index) + self.analyze_script(index=index) + + finfo.editor.notify_save() + + def file_saved_in_other_editorstack(self, original_filename, filename): + """ + File was just saved in another editorstack, let's synchronize! + This avoids file being automatically reloaded. + + The original filename is passed instead of an index in case the tabs + on the editor stacks were moved and are now in a different order - see + spyder-ide/spyder#5703. + Filename is passed in case file was just saved as another name. + """ + index = self.has_filename(original_filename) + if index is None: + return + finfo = self.data[index] + finfo.newly_created = False + finfo.filename = to_text_string(filename) + finfo.lastmodified = QFileInfo(finfo.filename).lastModified() + + def select_savename(self, original_filename): + """Select a name to save a file. + + Args: + original_filename: Used in the dialog to display the current file + path and name. + + Returns: + Normalized path for the selected file name or None if no name was + selected. + """ + if self.edit_filetypes is None: + self.edit_filetypes = get_edit_filetypes() + if self.edit_filters is None: + self.edit_filters = get_edit_filters() + + # Don't use filters on KDE to not make the dialog incredible + # slow + # Fixes spyder-ide/spyder#4156. + if is_kde_desktop() and not is_anaconda(): + filters = '' + selectedfilter = '' + else: + filters = self.edit_filters + selectedfilter = get_filter(self.edit_filetypes, + osp.splitext(original_filename)[1]) + + self.redirect_stdio.emit(False) + filename, _selfilter = getsavefilename(self, _("Save file"), + original_filename, + filters=filters, + selectedfilter=selectedfilter, + options=QFileDialog.HideNameFilterDetails) + self.redirect_stdio.emit(True) + if filename: + return osp.normpath(filename) + return None + + def save_as(self, index=None): + """Save file as... + + Args: + index: self.data index for the file to save. + + Returns: + False if no file name was selected or if save() was unsuccessful. + True is save() was successful. + + Gets the new file name from select_savename(). If no name is chosen, + then the save_as() aborts. Otherwise, the current stack is checked + to see if the selected name already exists and, if so, then the tab + with that name is closed. + + The current stack (self.data) and current tabs are updated with the + new name and other file info. The text is written with the new + name using save() and the name change is propagated to the other stacks + via the file_renamed_in_data signal. + """ + if index is None: + # Save the currently edited file + index = self.get_stack_index() + finfo = self.data[index] + original_newly_created = finfo.newly_created + # The next line is necessary to avoid checking if the file exists + # While running __check_file_status + # See spyder-ide/spyder#3678 and spyder-ide/spyder#3026. + finfo.newly_created = True + original_filename = finfo.filename + filename = self.select_savename(original_filename) + if filename: + ao_index = self.has_filename(filename) + # Note: ao_index == index --> saving an untitled file + if ao_index is not None and ao_index != index: + if not self.close_file(ao_index): + return + if ao_index < index: + index -= 1 + + new_index = self.rename_in_data(original_filename, + new_filename=filename) + + # We pass self object ID as a QString, because otherwise it would + # depend on the platform: long for 64bit, int for 32bit. Replacing + # by long all the time is not working on some 32bit platforms + # See spyder-ide/spyder#1094 and spyder-ide/spyder#1098. + self.file_renamed_in_data.emit(str(id(self)), + original_filename, filename) + + ok = self.save(index=new_index, force=True) + self.refresh(new_index) + self.set_stack_index(new_index) + return ok + else: + finfo.newly_created = original_newly_created + return False + + def save_copy_as(self, index=None): + """Save copy of file as... + + Args: + index: self.data index for the file to save. + + Returns: + False if no file name was selected or if save() was unsuccessful. + True is save() was successful. + + Gets the new file name from select_savename(). If no name is chosen, + then the save_copy_as() aborts. Otherwise, the current stack is + checked to see if the selected name already exists and, if so, then the + tab with that name is closed. + + Unlike save_as(), this calls write() directly instead of using save(). + The current file and tab aren't changed at all. The copied file is + opened in a new tab. + """ + if index is None: + # Save the currently edited file + index = self.get_stack_index() + finfo = self.data[index] + original_filename = finfo.filename + filename = self.select_savename(original_filename) + if filename: + ao_index = self.has_filename(filename) + # Note: ao_index == index --> saving an untitled file + if ao_index is not None and ao_index != index: + if not self.close_file(ao_index): + return + if ao_index < index: + index -= 1 + try: + self._write_to_file(finfo, filename) + # open created copy file + self.plugin_load.emit(filename) + return True + except EnvironmentError as error: + self.msgbox = QMessageBox( + QMessageBox.Critical, + _("Save Error"), + _("Unable to save file '%s'" + "

Error message:
%s" + ) % (osp.basename(finfo.filename), + str(error)), + parent=self) + self.msgbox.exec_() + else: + return False + + def save_all(self, save_new_files=True): + """Save all opened files. + + Iterate through self.data and call save() on any modified files. + """ + all_saved = True + for index in range(self.get_stack_count()): + if self.data[index].editor.document().isModified(): + all_saved &= self.save(index, save_new_files=save_new_files) + return all_saved + + #------ Update UI + def start_stop_analysis_timer(self): + self.is_analysis_done = False + self.analysis_timer.stop() + self.analysis_timer.start() + + def analyze_script(self, index=None): + """Analyze current script for TODOs.""" + if self.is_analysis_done: + return + if index is None: + index = self.get_stack_index() + if self.data and len(self.data) > index: + finfo = self.data[index] + if self.todolist_enabled: + finfo.run_todo_finder() + self.is_analysis_done = True + + def set_todo_results(self, filename, todo_results): + """Synchronize todo results between editorstacks""" + index = self.has_filename(filename) + if index is None: + return + self.data[index].set_todo_results(todo_results) + + def get_todo_results(self): + if self.data: + return self.data[self.get_stack_index()].todo_results + + def current_changed(self, index): + """Stack index has changed""" + editor = self.get_current_editor() + if index != -1: + editor.setFocus() + logger.debug("Set focus to: %s" % editor.filename) + else: + self.reset_statusbar.emit() + self.opened_files_list_changed.emit() + + self.stack_history.refresh() + self.stack_history.remove_and_append(index) + + # Needed to avoid an error generated after moving/renaming + # files outside Spyder while in debug mode. + # See spyder-ide/spyder#8749. + try: + logger.debug("Current changed: %d - %s" % + (index, self.data[index].editor.filename)) + except IndexError: + pass + + self.update_plugin_title.emit() + # Make sure that any replace happens in the editor on top + # See spyder-ide/spyder#9688. + self.find_widget.set_editor(editor, refresh=False) + + if editor is not None: + # Needed in order to handle the close of files open in a directory + # that has been renamed. See spyder-ide/spyder#5157. + try: + line, col = editor.get_cursor_line_column() + self.current_file_changed.emit(self.data[index].filename, + editor.get_position('cursor'), + line, col) + except IndexError: + pass + + def _get_previous_file_index(self): + """Return the penultimate element of the stack history.""" + try: + return self.stack_history[-2] + except IndexError: + return None + + def tab_navigation_mru(self, forward=True): + """ + Tab navigation with "most recently used" behaviour. + + It's fired when pressing 'go to previous file' or 'go to next file' + shortcuts. + + forward: + True: move to next file + False: move to previous file + """ + self.tabs_switcher = TabSwitcherWidget(self, self.stack_history, + self.tabs) + self.tabs_switcher.show() + self.tabs_switcher.select_row(1 if forward else -1) + self.tabs_switcher.setFocus() + + def focus_changed(self): + """Editor focus has changed""" + fwidget = QApplication.focusWidget() + for finfo in self.data: + if fwidget is finfo.editor: + if finfo.editor.operation_in_progress: + self.spinner.start() + else: + self.spinner.stop() + self.refresh() + self.editor_focus_changed.emit() + + def _refresh_outlineexplorer(self, index=None, update=True, clear=False): + """Refresh outline explorer panel""" + oe = self.outlineexplorer + if oe is None: + return + if index is None: + index = self.get_stack_index() + if self.data and len(self.data) > index: + finfo = self.data[index] + oe.setEnabled(True) + oe.set_current_editor(finfo.editor.oe_proxy, + update=update, clear=clear) + if index != self.get_stack_index(): + # The last file added to the outline explorer is not the + # currently focused one in the editor stack. Therefore, + # we need to force a refresh of the outline explorer to set + # the current editor to the currently focused one in the + # editor stack. See spyder-ide/spyder#8015. + self._refresh_outlineexplorer(update=False) + return + self._sync_outlineexplorer_file_order() + + def _sync_outlineexplorer_file_order(self): + """ + Order the root file items of the outline explorer as in the tabbar + of the current EditorStack. + """ + if self.outlineexplorer is not None: + self.outlineexplorer.treewidget.set_editor_ids_order( + [finfo.editor.get_document_id() for finfo in self.data]) + + def __refresh_statusbar(self, index): + """Refreshing statusbar widgets""" + if self.data and len(self.data) > index: + finfo = self.data[index] + self.encoding_changed.emit(finfo.encoding) + # Refresh cursor position status: + line, index = finfo.editor.get_cursor_line_column() + self.sig_editor_cursor_position_changed.emit(line, index) + + def __refresh_readonly(self, index): + if self.data and len(self.data) > index: + finfo = self.data[index] + read_only = not QFileInfo(finfo.filename).isWritable() + if not osp.isfile(finfo.filename): + # This is an 'untitledX.py' file (newly created) + read_only = False + elif os.name == 'nt': + try: + # Try to open the file to see if its permissions allow + # to write on it + # Fixes spyder-ide/spyder#10657 + fd = os.open(finfo.filename, os.O_RDWR) + os.close(fd) + except (IOError, OSError): + read_only = True + finfo.editor.setReadOnly(read_only) + self.readonly_changed.emit(read_only) + + def __check_file_status(self, index): + """Check if file has been changed in any way outside Spyder: + 1. removed, moved or renamed outside Spyder + 2. modified outside Spyder""" + if self.__file_status_flag: + # Avoid infinite loop: when the QMessageBox.question pops, it + # gets focus and then give it back to the CodeEditor instance, + # triggering a refresh cycle which calls this method + return + self.__file_status_flag = True + + if len(self.data) <= index: + index = self.get_stack_index() + + finfo = self.data[index] + name = osp.basename(finfo.filename) + + if finfo.newly_created: + # File was just created (not yet saved): do nothing + # (do not return because of the clean-up at the end of the method) + pass + + elif not osp.isfile(finfo.filename): + # File doesn't exist (removed, moved or offline): + self.msgbox = QMessageBox( + QMessageBox.Warning, + self.title, + _("%s is unavailable " + "(this file may have been removed, moved " + "or renamed outside Spyder)." + "
Do you want to close it?") % name, + QMessageBox.Yes | QMessageBox.No, + self) + answer = self.msgbox.exec_() + if answer == QMessageBox.Yes: + self.close_file(index) + else: + finfo.newly_created = True + finfo.editor.document().setModified(True) + self.modification_changed(index=index) + + else: + # Else, testing if it has been modified elsewhere: + lastm = QFileInfo(finfo.filename).lastModified() + if to_text_string(lastm.toString()) \ + != to_text_string(finfo.lastmodified.toString()): + if finfo.editor.document().isModified(): + self.msgbox = QMessageBox( + QMessageBox.Question, + self.title, + _("%s has been modified outside Spyder." + "
Do you want to reload it and lose all " + "your changes?") % name, + QMessageBox.Yes | QMessageBox.No, + self) + answer = self.msgbox.exec_() + if answer == QMessageBox.Yes: + self.reload(index) + else: + finfo.lastmodified = lastm + else: + self.reload(index) + + # Finally, resetting temporary flag: + self.__file_status_flag = False + + def __modify_stack_title(self): + for index, finfo in enumerate(self.data): + state = finfo.editor.document().isModified() + self.set_stack_title(index, state) + + def refresh(self, index=None): + """Refresh tabwidget""" + if index is None: + index = self.get_stack_index() + # Set current editor + if self.get_stack_count(): + index = self.get_stack_index() + finfo = self.data[index] + editor = finfo.editor + editor.setFocus() + self._refresh_outlineexplorer(index, update=False) + self.update_code_analysis_actions.emit() + self.__refresh_statusbar(index) + self.__refresh_readonly(index) + self.__check_file_status(index) + self.__modify_stack_title() + self.update_plugin_title.emit() + else: + editor = None + # Update the modification-state-dependent parameters + self.modification_changed() + # Update FindReplace binding + self.find_widget.set_editor(editor, refresh=False) + + def modification_changed(self, state=None, index=None, editor_id=None): + """ + Current editor's modification state has changed + --> change tab title depending on new modification state + --> enable/disable save/save all actions + """ + if editor_id is not None: + for index, _finfo in enumerate(self.data): + if id(_finfo.editor) == editor_id: + break + # This must be done before refreshing save/save all actions: + # (otherwise Save/Save all actions will always be enabled) + self.opened_files_list_changed.emit() + # -- + if index is None: + index = self.get_stack_index() + if index == -1: + return + finfo = self.data[index] + if state is None: + state = finfo.editor.document().isModified() or finfo.newly_created + self.set_stack_title(index, state) + # Toggle save/save all actions state + self.save_action.setEnabled(state) + self.refresh_save_all_action.emit() + # Refreshing eol mode + eol_chars = finfo.editor.get_line_separator() + self.refresh_eol_chars(eol_chars) + self.stack_history.refresh() + + def refresh_eol_chars(self, eol_chars): + os_name = sourcecode.get_os_name_from_eol_chars(eol_chars) + self.sig_refresh_eol_chars.emit(os_name) + + # ------ Load, reload + def reload(self, index): + """Reload file from disk.""" + finfo = self.data[index] + logger.debug("Reloading {}".format(finfo.filename)) + + txt, finfo.encoding = encoding.read(finfo.filename) + finfo.lastmodified = QFileInfo(finfo.filename).lastModified() + position = finfo.editor.get_position('cursor') + finfo.editor.set_text(txt) + finfo.editor.document().setModified(False) + self.autosave.file_hashes[finfo.filename] = hash(txt) + finfo.editor.set_cursor_position(position) + + #XXX CodeEditor-only: re-scan the whole text to rebuild outline + # explorer data from scratch (could be optimized because + # rehighlighting text means searching for all syntax coloring + # patterns instead of only searching for class/def patterns which + # would be sufficient for outline explorer data. + finfo.editor.rehighlight() + + def revert(self): + """Revert file from disk.""" + index = self.get_stack_index() + finfo = self.data[index] + logger.debug("Reverting {}".format(finfo.filename)) + + filename = finfo.filename + if finfo.editor.document().isModified(): + self.msgbox = QMessageBox( + QMessageBox.Warning, + self.title, + _("All changes to %s will be lost." + "
Do you want to revert file from disk?" + ) % osp.basename(filename), + QMessageBox.Yes | QMessageBox.No, + self) + answer = self.msgbox.exec_() + if answer != QMessageBox.Yes: + return + self.reload(index) + + def create_new_editor(self, fname, enc, txt, set_current, new=False, + cloned_from=None, add_where='end'): + """ + Create a new editor instance + Returns finfo object (instead of editor as in previous releases) + """ + editor = codeeditor.CodeEditor(self) + editor.go_to_definition.connect( + lambda fname, line, column: self.sig_go_to_definition.emit( + fname, line, column)) + + finfo = FileInfo(fname, enc, editor, new, self.threadmanager) + + self.add_to_data(finfo, set_current, add_where) + finfo.sig_send_to_help.connect(self.send_to_help) + finfo.sig_show_object_info.connect(self.inspect_current_object) + finfo.todo_results_changed.connect( + lambda: self.todo_results_changed.emit()) + finfo.edit_goto.connect(lambda fname, lineno, name: + self.edit_goto.emit(fname, lineno, name)) + finfo.sig_save_bookmarks.connect(lambda s1, s2: + self.sig_save_bookmarks.emit(s1, s2)) + editor.sig_run_selection.connect(self.run_selection) + editor.sig_run_to_line.connect(self.run_to_line) + editor.sig_run_from_line.connect(self.run_from_line) + editor.sig_run_cell.connect(self.run_cell) + editor.sig_debug_cell.connect(self.debug_cell) + editor.sig_run_cell_and_advance.connect(self.run_cell_and_advance) + editor.sig_re_run_last_cell.connect(self.re_run_last_cell) + editor.sig_new_file.connect(self.sig_new_file) + editor.sig_breakpoints_saved.connect(self.sig_breakpoints_saved) + editor.sig_process_code_analysis.connect( + lambda: self.update_code_analysis_actions.emit()) + editor.sig_refresh_formatting.connect(self.sig_refresh_formatting) + language = get_file_language(fname, txt) + editor.setup_editor( + linenumbers=self.linenumbers_enabled, + show_blanks=self.blanks_enabled, + underline_errors=self.underline_errors_enabled, + scroll_past_end=self.scrollpastend_enabled, + edge_line=self.edgeline_enabled, + edge_line_columns=self.edgeline_columns, + language=language, + markers=self.has_markers(), + font=self.default_font, + color_scheme=self.color_scheme, + wrap=self.wrap_enabled, + tab_mode=self.tabmode_enabled, + strip_mode=self.stripmode_enabled, + intelligent_backspace=self.intelligent_backspace_enabled, + automatic_completions=self.automatic_completions_enabled, + automatic_completions_after_chars=self.automatic_completion_chars, + automatic_completions_after_ms=self.automatic_completion_ms, + code_snippets=self.code_snippets_enabled, + completions_hint=self.completions_hint_enabled, + completions_hint_after_ms=self.completions_hint_after_ms, + hover_hints=self.hover_hints_enabled, + highlight_current_line=self.highlight_current_line_enabled, + highlight_current_cell=self.highlight_current_cell_enabled, + occurrence_highlighting=self.occurrence_highlighting_enabled, + occurrence_timeout=self.occurrence_highlighting_timeout, + close_parentheses=self.close_parentheses_enabled, + close_quotes=self.close_quotes_enabled, + add_colons=self.add_colons_enabled, + auto_unindent=self.auto_unindent_enabled, + indent_chars=self.indent_chars, + tab_stop_width_spaces=self.tab_stop_width_spaces, + cloned_from=cloned_from, + filename=fname, + show_class_func_dropdown=self.show_class_func_dropdown, + indent_guides=self.indent_guides, + folding=self.code_folding_enabled, + remove_trailing_spaces=self.always_remove_trailing_spaces, + remove_trailing_newlines=self.remove_trailing_newlines, + add_newline=self.add_newline, + format_on_save=self.format_on_save + ) + if cloned_from is None: + editor.set_text(txt) + editor.document().setModified(False) + finfo.text_changed_at.connect( + lambda fname, position: + self.text_changed_at.emit(fname, position)) + editor.sig_cursor_position_changed.connect( + self.editor_cursor_position_changed) + editor.textChanged.connect(self.start_stop_analysis_timer) + + # Register external panels + for panel_class, args, kwargs, position in self.external_panels: + self.register_panel( + panel_class, *args, position=position, **kwargs) + + def perform_completion_request(lang, method, params): + self.sig_perform_completion_request.emit(lang, method, params) + + editor.sig_perform_completion_request.connect( + perform_completion_request) + editor.sig_start_operation_in_progress.connect(self.spinner.start) + editor.sig_stop_operation_in_progress.connect(self.spinner.stop) + editor.modificationChanged.connect( + lambda state: self.modification_changed( + state, editor_id=id(editor))) + editor.focus_in.connect(self.focus_changed) + editor.zoom_in.connect(lambda: self.zoom_in.emit()) + editor.zoom_out.connect(lambda: self.zoom_out.emit()) + editor.zoom_reset.connect(lambda: self.zoom_reset.emit()) + editor.sig_eol_chars_changed.connect( + lambda eol_chars: self.refresh_eol_chars(eol_chars)) + editor.sig_next_cursor.connect(self.sig_next_cursor) + editor.sig_prev_cursor.connect(self.sig_prev_cursor) + + self.find_widget.set_editor(editor) + + self.refresh_file_dependent_actions.emit() + self.modification_changed(index=self.data.index(finfo)) + + # To update the outline explorer. + editor.oe_proxy = OutlineExplorerProxyEditor(editor, editor.filename) + if self.outlineexplorer is not None: + self.outlineexplorer.register_editor(editor.oe_proxy) + + # Needs to reset the highlighting on startup in case the PygmentsSH + # is in use + editor.run_pygments_highlighter() + options = { + 'language': editor.language, + 'filename': editor.filename, + 'codeeditor': editor + } + self.sig_open_file.emit(options) + if self.get_stack_index() == 0: + self.current_changed(0) + + return finfo + + def editor_cursor_position_changed(self, line, index): + """Cursor position of one of the editor in the stack has changed""" + self.sig_editor_cursor_position_changed.emit(line, index) + + @Slot(str, str, bool) + def send_to_help(self, name, signature, force=False): + """qstr1: obj_text, qstr2: argpspec, qstr3: note, qstr4: doc_text""" + if not force and not self.help_enabled: + return + + editor = self.get_current_editor() + language = editor.language.lower() + signature = to_text_string(signature) + signature = unicodedata.normalize("NFKD", signature) + parts = signature.split('\n\n') + definition = parts[0] + documentation = '\n\n'.join(parts[1:]) + args = '' + + if '(' in definition and language == 'python': + args = definition[definition.find('('):] + else: + documentation = signature + + doc = { + 'obj_text': '', + 'name': name, + 'argspec': args, + 'note': '', + 'docstring': documentation, + 'force_refresh': force, + 'path': editor.filename + } + self.sig_help_requested.emit(doc) + + def new(self, filename, encoding, text, default_content=False, + empty=False): + """ + Create new filename with *encoding* and *text* + """ + finfo = self.create_new_editor(filename, encoding, text, + set_current=False, new=True) + finfo.editor.set_cursor_position('eof') + if not empty: + finfo.editor.insert_text(os.linesep) + if default_content: + finfo.default = True + finfo.editor.document().setModified(False) + return finfo + + def load(self, filename, set_current=True, add_where='end', + processevents=True): + """ + Load filename, create an editor instance and return it + + This also sets the hash of the loaded file in the autosave component. + + *Warning* This is loading file, creating editor but not executing + the source code analysis -- the analysis must be done by the editor + plugin (in case multiple editorstack instances are handled) + """ + filename = osp.abspath(to_text_string(filename)) + if processevents: + self.starting_long_process.emit(_("Loading %s...") % filename) + text, enc = encoding.read(filename) + self.autosave.file_hashes[filename] = hash(text) + finfo = self.create_new_editor(filename, enc, text, set_current, + add_where=add_where) + index = self.data.index(finfo) + if processevents: + self.ending_long_process.emit("") + if self.isVisible() and self.checkeolchars_enabled \ + and sourcecode.has_mixed_eol_chars(text): + name = osp.basename(filename) + self.msgbox = QMessageBox( + QMessageBox.Warning, + self.title, + _("%s contains mixed end-of-line " + "characters.
Spyder will fix this " + "automatically.") % name, + QMessageBox.Ok, + self) + self.msgbox.exec_() + self.set_os_eol_chars(index) + self.is_analysis_done = False + self.analyze_script(index) + finfo.editor.set_sync_symbols_and_folding_timeout() + return finfo + + def set_os_eol_chars(self, index=None, osname=None): + """ + Sets the EOL character(s) based on the operating system. + + If `osname` is None, then the default line endings for the current + operating system will be used. + + `osname` can be one of: 'posix', 'nt', 'mac'. + """ + if osname is None: + if os.name == 'nt': + osname = 'nt' + elif sys.platform == 'darwin': + osname = 'mac' + else: + osname = 'posix' + + if index is None: + index = self.get_stack_index() + + finfo = self.data[index] + eol_chars = sourcecode.get_eol_chars_from_os_name(osname) + logger.debug(f"Set OS eol chars {eol_chars} for file {finfo.filename}") + finfo.editor.set_eol_chars(eol_chars=eol_chars) + finfo.editor.document().setModified(True) + + def remove_trailing_spaces(self, index=None): + """Remove trailing spaces""" + if index is None: + index = self.get_stack_index() + finfo = self.data[index] + logger.debug(f"Remove trailing spaces for file {finfo.filename}") + finfo.editor.trim_trailing_spaces() + + def trim_trailing_newlines(self, index=None): + if index is None: + index = self.get_stack_index() + finfo = self.data[index] + logger.debug(f"Trim trailing new lines for file {finfo.filename}") + finfo.editor.trim_trailing_newlines() + + def add_newline_to_file(self, index=None): + if index is None: + index = self.get_stack_index() + finfo = self.data[index] + logger.debug(f"Add new line to file {finfo.filename}") + finfo.editor.add_newline_to_file() + + def fix_indentation(self, index=None): + """Replace tab characters by spaces""" + if index is None: + index = self.get_stack_index() + finfo = self.data[index] + logger.debug(f"Fix indentation for file {finfo.filename}") + finfo.editor.fix_indentation() + + def format_document_or_selection(self, index=None): + if index is None: + index = self.get_stack_index() + finfo = self.data[index] + logger.debug(f"Run formatting in file {finfo.filename}") + finfo.editor.format_document_or_range() + + # ------ Run + def _run_lines_cursor(self, direction): + """ Select and run all lines from cursor in given direction""" + editor = self.get_current_editor() + + # Move cursor to start of line then move to beginning or end of + # document with KeepAnchor + cursor = editor.textCursor() + cursor.movePosition(QTextCursor.StartOfLine) + + if direction == 'up': + cursor.movePosition(QTextCursor.Start, QTextCursor.KeepAnchor) + elif direction == 'down': + cursor.movePosition(QTextCursor.End, QTextCursor.KeepAnchor) + + code_text = editor.get_selection_as_executable_code(cursor) + if code_text: + self.exec_in_extconsole.emit(code_text.rstrip(), + self.focus_to_editor) + + def run_to_line(self): + """ + Run all lines from the beginning up to, but not including, current + line. + """ + self._run_lines_cursor(direction='up') + + def run_from_line(self): + """ + Run all lines from and including the current line to the end of + the document. + """ + self._run_lines_cursor(direction='down') + + def run_selection(self): + """ + Run selected text or current line in console. + + If some text is selected, then execute that text in console. + + If no text is selected, then execute current line, unless current line + is empty. Then, advance cursor to next line. If cursor is on last line + and that line is not empty, then add a new blank line and move the + cursor there. If cursor is on last line and that line is empty, then do + not move cursor. + """ + text = self.get_current_editor().get_selection_as_executable_code() + if text: + self.exec_in_extconsole.emit(text.rstrip(), self.focus_to_editor) + return + editor = self.get_current_editor() + line = editor.get_current_line() + text = line.lstrip() + if text: + self.exec_in_extconsole.emit(text, self.focus_to_editor) + if editor.is_cursor_on_last_line() and text: + editor.append(editor.get_line_separator()) + editor.move_cursor_to_next('line', 'down') + + def run_cell(self, debug=False): + """Run current cell.""" + text, block = self.get_current_editor().get_cell_as_executable_code() + finfo = self.get_current_finfo() + editor = self.get_current_editor() + name = cell_name(block) + filename = finfo.filename + + self._run_cell_text(text, editor, (filename, name), debug) + + def debug_cell(self): + """Debug current cell.""" + self.run_cell(debug=True) + + def run_cell_and_advance(self): + """Run current cell and advance to the next one""" + self.run_cell() + self.advance_cell() + + def advance_cell(self, reverse=False): + """Advance to the next cell. + + reverse = True --> go to previous cell. + """ + if not reverse: + move_func = self.get_current_editor().go_to_next_cell + else: + move_func = self.get_current_editor().go_to_previous_cell + + move_func() + + def re_run_last_cell(self): + """Run the previous cell again.""" + if self.last_cell_call is None: + return + filename, cell_name = self.last_cell_call + index = self.has_filename(filename) + if index is None: + return + editor = self.data[index].editor + + try: + text = editor.get_cell_code(cell_name) + except RuntimeError: + return + + self._run_cell_text(text, editor, (filename, cell_name)) + + def _run_cell_text(self, text, editor, cell_id, debug=False): + """Run cell code in the console. + + Cell code is run in the console by copying it to the console if + `self.run_cell_copy` is ``True`` otherwise by using the `run_cell` + function. + + Parameters + ---------- + text : str + The code in the cell as a string. + line : int + The starting line number of the cell in the file. + """ + (filename, cell_name) = cell_id + if editor.is_python_or_ipython(): + args = (text, cell_name, filename, self.run_cell_copy, + self.focus_to_editor) + if debug: + self.debug_cell_in_ipyclient.emit(*args) + else: + self.run_cell_in_ipyclient.emit(*args) + + # ------ Drag and drop + def dragEnterEvent(self, event): + """ + Reimplemented Qt method. + + Inform Qt about the types of data that the widget accepts. + """ + logger.debug("dragEnterEvent was received") + source = event.mimeData() + # The second check is necessary on Windows, where source.hasUrls() + # can return True but source.urls() is [] + # The third check is needed since a file could be dropped from + # compressed files. In Windows mimedata2url(source) returns None + # Fixes spyder-ide/spyder#5218. + has_urls = source.hasUrls() + has_text = source.hasText() + urls = source.urls() + all_urls = mimedata2url(source) + logger.debug("Drag event source has_urls: {}".format(has_urls)) + logger.debug("Drag event source urls: {}".format(urls)) + logger.debug("Drag event source all_urls: {}".format(all_urls)) + logger.debug("Drag event source has_text: {}".format(has_text)) + if has_urls and urls and all_urls: + text = [encoding.is_text_file(url) for url in all_urls] + logger.debug("Accept proposed action?: {}".format(any(text))) + if any(text): + event.acceptProposedAction() + else: + event.ignore() + elif source.hasText(): + event.acceptProposedAction() + elif os.name == 'nt': + # This covers cases like dragging from compressed files, + # which can be opened by the Editor if they are plain + # text, but doesn't come with url info. + # Fixes spyder-ide/spyder#2032. + logger.debug("Accept proposed action on Windows") + event.acceptProposedAction() + else: + logger.debug("Ignore drag event") + event.ignore() + + def dropEvent(self, event): + """ + Reimplement Qt method. + + Unpack dropped data and handle it. + """ + logger.debug("dropEvent was received") + source = event.mimeData() + # The second check is necessary when mimedata2url(source) + # returns None. + # Fixes spyder-ide/spyder#7742. + if source.hasUrls() and mimedata2url(source): + files = mimedata2url(source) + files = [f for f in files if encoding.is_text_file(f)] + files = set(files or []) + for fname in files: + self.plugin_load.emit(fname) + elif source.hasText(): + editor = self.get_current_editor() + if editor is not None: + editor.insert_text(source.text()) + else: + event.ignore() + event.acceptProposedAction() + + def register_panel(self, panel_class, *args, + position=Panel.Position.LEFT, **kwargs): + """Register a panel in all codeeditors.""" + if (panel_class, args, kwargs, position) not in self.external_panels: + self.external_panels.append((panel_class, args, kwargs, position)) + for finfo in self.data: + cur_panel = finfo.editor.panels.register( + panel_class(*args, **kwargs), position=position) + if not cur_panel.isVisible(): + cur_panel.setVisible(True) + + +class EditorSplitter(QSplitter): + """QSplitter for editor windows.""" + + def __init__(self, parent, plugin, menu_actions, first=False, + register_editorstack_cb=None, unregister_editorstack_cb=None): + """Create a splitter for dividing an editor window into panels. + + Adds a new EditorStack instance to this splitter. If it's not + the first splitter, clones the current EditorStack from the plugin. + + Args: + parent: Parent widget. + plugin: Plugin this widget belongs to. + menu_actions: QActions to include from the parent. + first: Boolean if this is the first splitter in the editor. + register_editorstack_cb: Callback to register the EditorStack. + Defaults to plugin.register_editorstack() to + register the EditorStack with the Editor plugin. + unregister_editorstack_cb: Callback to unregister the EditorStack. + Defaults to plugin.unregister_editorstack() to + unregister the EditorStack with the Editor plugin. + """ + + QSplitter.__init__(self, parent) + self.setAttribute(Qt.WA_DeleteOnClose) + self.setChildrenCollapsible(False) + + self.toolbar_list = None + self.menu_list = None + + self.plugin = plugin + + if register_editorstack_cb is None: + register_editorstack_cb = self.plugin.register_editorstack + self.register_editorstack_cb = register_editorstack_cb + if unregister_editorstack_cb is None: + unregister_editorstack_cb = self.plugin.unregister_editorstack + self.unregister_editorstack_cb = unregister_editorstack_cb + + self.menu_actions = menu_actions + self.editorstack = EditorStack(self, menu_actions) + self.register_editorstack_cb(self.editorstack) + if not first: + self.plugin.clone_editorstack(editorstack=self.editorstack) + self.editorstack.destroyed.connect(lambda: self.editorstack_closed()) + self.editorstack.sig_split_vertically.connect( + lambda: self.split(orientation=Qt.Vertical)) + self.editorstack.sig_split_horizontally.connect( + lambda: self.split(orientation=Qt.Horizontal)) + self.addWidget(self.editorstack) + + if not running_under_pytest(): + self.editorstack.set_color_scheme(plugin.get_color_scheme()) + + self.setStyleSheet(self._stylesheet) + + def closeEvent(self, event): + """Override QWidget closeEvent(). + + This event handler is called with the given event when Qt + receives a window close request from a top-level widget. + """ + QSplitter.closeEvent(self, event) + + def __give_focus_to_remaining_editor(self): + focus_widget = self.plugin.get_focus_widget() + if focus_widget is not None: + focus_widget.setFocus() + + def editorstack_closed(self): + try: + logger.debug("method 'editorstack_closed':") + logger.debug(" self : %r" % self) + self.unregister_editorstack_cb(self.editorstack) + self.editorstack = None + close_splitter = self.count() == 1 + if close_splitter: + # editorstack just closed was the last widget in this QSplitter + self.close() + return + self.__give_focus_to_remaining_editor() + except (RuntimeError, AttributeError): + # editorsplitter has been destroyed (happens when closing a + # EditorMainWindow instance) + return + + def editorsplitter_closed(self): + logger.debug("method 'editorsplitter_closed':") + logger.debug(" self : %r" % self) + try: + close_splitter = self.count() == 1 and self.editorstack is None + except RuntimeError: + # editorsplitter has been destroyed (happens when closing a + # EditorMainWindow instance) + return + if close_splitter: + # editorsplitter just closed was the last widget in this QSplitter + self.close() + return + elif self.count() == 2 and self.editorstack: + # back to the initial state: a single editorstack instance, + # as a single widget in this QSplitter: orientation may be changed + self.editorstack.reset_orientation() + self.__give_focus_to_remaining_editor() + + def split(self, orientation=Qt.Vertical): + """Create and attach a new EditorSplitter to the current EditorSplitter. + + The new EditorSplitter widget will contain an EditorStack that + is a clone of the current EditorStack. + + A single EditorSplitter instance can be split multiple times, but the + orientation will be the same for all the direct splits. If one of + the child splits is split, then that split can have a different + orientation. + """ + self.setOrientation(orientation) + self.editorstack.set_orientation(orientation) + editorsplitter = EditorSplitter(self.parent(), self.plugin, + self.menu_actions, + register_editorstack_cb=self.register_editorstack_cb, + unregister_editorstack_cb=self.unregister_editorstack_cb) + self.addWidget(editorsplitter) + editorsplitter.destroyed.connect(self.editorsplitter_closed) + current_editor = editorsplitter.editorstack.get_current_editor() + if current_editor is not None: + current_editor.setFocus() + + def iter_editorstacks(self): + """Return the editor stacks for this splitter and every first child. + + Note: If a splitter contains more than one splitter as a direct + child, only the first child's editor stack is included. + + Returns: + List of tuples containing (EditorStack instance, orientation). + """ + editorstacks = [(self.widget(0), self.orientation())] + if self.count() > 1: + editorsplitter = self.widget(1) + editorstacks += editorsplitter.iter_editorstacks() + return editorstacks + + def get_layout_settings(self): + """Return the layout state for this splitter and its children. + + Record the current state, including file names and current line + numbers, of the splitter panels. + + Returns: + A dictionary containing keys {hexstate, sizes, splitsettings}. + hexstate: String of saveState() for self. + sizes: List for size() for self. + splitsettings: List of tuples of the form + (orientation, cfname, clines) for each EditorSplitter + and its EditorStack. + orientation: orientation() for the editor + splitter (which may be a child of self). + cfname: EditorStack current file name. + clines: Current line number for each file in the + EditorStack. + """ + splitsettings = [] + for editorstack, orientation in self.iter_editorstacks(): + clines = [] + cfname = '' + # XXX - this overrides value from the loop to always be False? + orientation = False + if hasattr(editorstack, 'data'): + clines = [finfo.editor.get_cursor_line_number() + for finfo in editorstack.data] + cfname = editorstack.get_current_filename() + splitsettings.append((orientation == Qt.Vertical, cfname, clines)) + return dict(hexstate=qbytearray_to_str(self.saveState()), + sizes=self.sizes(), splitsettings=splitsettings) + + def set_layout_settings(self, settings, dont_goto=None): + """Restore layout state for the splitter panels. + + Apply the settings to restore a saved layout within the editor. If + the splitsettings key doesn't exist, then return without restoring + any settings. + + The current EditorSplitter (self) calls split() for each element + in split_settings, thus recreating the splitter panels from the saved + state. split() also clones the editorstack, which is then + iterated over to restore the saved line numbers on each file. + + The size and positioning of each splitter panel is restored from + hexstate. + + Args: + settings: A dictionary with keys {hexstate, sizes, orientation} + that define the layout for the EditorSplitter panels. + dont_goto: Defaults to None, which positions the cursor to the + end of the editor. If there's a value, positions the + cursor on the saved line number for each editor. + """ + splitsettings = settings.get('splitsettings') + if splitsettings is None: + return + splitter = self + editor = None + for i, (is_vertical, cfname, clines) in enumerate(splitsettings): + if i > 0: + splitter.split(Qt.Vertical if is_vertical else Qt.Horizontal) + splitter = splitter.widget(1) + editorstack = splitter.widget(0) + for j, finfo in enumerate(editorstack.data): + editor = finfo.editor + # TODO: go_to_line is not working properly (the line it jumps + # to is not the corresponding to that file). This will be fixed + # in a future PR (which will fix spyder-ide/spyder#3857). + if dont_goto is not None: + # Skip go to line for first file because is already there. + pass + else: + try: + editor.go_to_line(clines[j]) + except IndexError: + pass + hexstate = settings.get('hexstate') + if hexstate is not None: + self.restoreState( QByteArray().fromHex( + str(hexstate).encode('utf-8')) ) + sizes = settings.get('sizes') + if sizes is not None: + self.setSizes(sizes) + if editor is not None: + editor.clearFocus() + editor.setFocus() + + @property + def _stylesheet(self): + css = qstylizer.style.StyleSheet() + css.QSplitter.setValues( + background=QStylePalette.COLOR_BACKGROUND_1 + ) + return css.toString() + + +class EditorWidget(QSplitter): + CONF_SECTION = 'editor' + + def __init__(self, parent, plugin, menu_actions): + QSplitter.__init__(self, parent) + self.setAttribute(Qt.WA_DeleteOnClose) + + statusbar = parent.statusBar() # Create a status bar + self.vcs_status = VCSStatus(self) + self.cursorpos_status = CursorPositionStatus(self) + self.encoding_status = EncodingStatus(self) + self.eol_status = EOLStatus(self) + self.readwrite_status = ReadWriteStatus(self) + + statusbar.insertPermanentWidget(0, self.readwrite_status) + statusbar.insertPermanentWidget(0, self.eol_status) + statusbar.insertPermanentWidget(0, self.encoding_status) + statusbar.insertPermanentWidget(0, self.cursorpos_status) + statusbar.insertPermanentWidget(0, self.vcs_status) + + self.editorstacks = [] + + self.plugin = plugin + + self.find_widget = FindReplace(self, enable_replace=True) + self.plugin.register_widget_shortcuts(self.find_widget) + self.find_widget.hide() + + # TODO: Check this initialization once the editor is migrated to the + # new API + self.outlineexplorer = OutlineExplorerWidget( + 'outline_explorer', + plugin, + self, + context=f'editor_window_{str(id(self))}' + ) + self.outlineexplorer.edit_goto.connect( + lambda filenames, goto, word: + plugin.load(filenames=filenames, goto=goto, word=word, + editorwindow=self.parent())) + + editor_widgets = QWidget(self) + editor_layout = QVBoxLayout() + editor_layout.setContentsMargins(0, 0, 0, 0) + editor_widgets.setLayout(editor_layout) + editorsplitter = EditorSplitter(self, plugin, menu_actions, + register_editorstack_cb=self.register_editorstack, + unregister_editorstack_cb=self.unregister_editorstack) + self.editorsplitter = editorsplitter + editor_layout.addWidget(editorsplitter) + editor_layout.addWidget(self.find_widget) + + splitter = QSplitter(self) + splitter.setContentsMargins(0, 0, 0, 0) + splitter.addWidget(editor_widgets) + splitter.addWidget(self.outlineexplorer) + splitter.setStretchFactor(0, 5) + splitter.setStretchFactor(1, 1) + + def register_editorstack(self, editorstack): + self.editorstacks.append(editorstack) + logger.debug("EditorWidget.register_editorstack: %r" % editorstack) + self.__print_editorstacks() + self.plugin.last_focused_editorstack[self.parent()] = editorstack + editorstack.set_closable(len(self.editorstacks) > 1) + editorstack.set_outlineexplorer(self.outlineexplorer) + editorstack.set_find_widget(self.find_widget) + editorstack.reset_statusbar.connect(self.readwrite_status.hide) + editorstack.reset_statusbar.connect(self.encoding_status.hide) + editorstack.reset_statusbar.connect(self.cursorpos_status.hide) + editorstack.readonly_changed.connect( + self.readwrite_status.update_readonly) + editorstack.encoding_changed.connect( + self.encoding_status.update_encoding) + editorstack.sig_editor_cursor_position_changed.connect( + self.cursorpos_status.update_cursor_position) + editorstack.sig_refresh_eol_chars.connect(self.eol_status.update_eol) + self.plugin.register_editorstack(editorstack) + + def __print_editorstacks(self): + logger.debug("%d editorstack(s) in editorwidget:" % + len(self.editorstacks)) + for edst in self.editorstacks: + logger.debug(" %r" % edst) + + def unregister_editorstack(self, editorstack): + logger.debug("EditorWidget.unregister_editorstack: %r" % editorstack) + self.plugin.unregister_editorstack(editorstack) + self.editorstacks.pop(self.editorstacks.index(editorstack)) + self.__print_editorstacks() + + +class EditorMainWindow(QMainWindow): + def __init__( + self, plugin, menu_actions, toolbar_list, menu_list, parent=None): + # Parent needs to be `None` if the the created widget is meant to be + # independent. See spyder-ide/spyder#17803 + QMainWindow.__init__(self, parent) + self.setAttribute(Qt.WA_DeleteOnClose) + + self.plugin = plugin + self.window_size = None + + self.editorwidget = EditorWidget(self, plugin, menu_actions) + self.setCentralWidget(self.editorwidget) + + # Setting interface theme + self.setStyleSheet(str(APP_STYLESHEET)) + + # Give focus to current editor to update/show all status bar widgets + editorstack = self.editorwidget.editorsplitter.editorstack + editor = editorstack.get_current_editor() + if editor is not None: + editor.setFocus() + + self.setWindowTitle("Spyder - %s" % plugin.windowTitle()) + self.setWindowIcon(plugin.windowIcon()) + + if toolbar_list: + self.toolbars = [] + for title, object_name, actions in toolbar_list: + toolbar = self.addToolBar(title) + toolbar.setObjectName(object_name) + toolbar.setStyleSheet(str(APP_TOOLBAR_STYLESHEET)) + toolbar.setMovable(False) + add_actions(toolbar, actions) + self.toolbars.append(toolbar) + if menu_list: + quit_action = create_action(self, _("Close window"), + icon=ima.icon("close_pane"), + tip=_("Close this window"), + triggered=self.close) + self.menus = [] + for index, (title, actions) in enumerate(menu_list): + menu = self.menuBar().addMenu(title) + if index == 0: + # File menu + add_actions(menu, actions+[None, quit_action]) + else: + add_actions(menu, actions) + self.menus.append(menu) + + def get_toolbars(self): + """Get the toolbars.""" + return self.toolbars + + def add_toolbars_to_menu(self, menu_title, actions): + """Add toolbars to a menu.""" + # Six is the position of the view menu in menus list + # that you can find in plugins/editor.py setup_other_windows. + view_menu = self.menus[6] + view_menu.setObjectName('checkbox-padding') + if actions == self.toolbars and view_menu: + toolbars = [] + for toolbar in self.toolbars: + action = toolbar.toggleViewAction() + toolbars.append(action) + add_actions(view_menu, toolbars) + + def load_toolbars(self): + """Loads the last visible toolbars from the .ini file.""" + toolbars_names = CONF.get('main', 'last_visible_toolbars', default=[]) + if toolbars_names: + dic = {} + for toolbar in self.toolbars: + dic[toolbar.objectName()] = toolbar + toolbar.toggleViewAction().setChecked(False) + toolbar.setVisible(False) + for name in toolbars_names: + if name in dic: + dic[name].toggleViewAction().setChecked(True) + dic[name].setVisible(True) + + def resizeEvent(self, event): + """Reimplement Qt method""" + if not self.isMaximized() and not self.isFullScreen(): + self.window_size = self.size() + QMainWindow.resizeEvent(self, event) + + def closeEvent(self, event): + """Reimplement Qt method""" + if self.plugin._undocked_window is not None: + self.plugin.dockwidget.setWidget(self.plugin) + self.plugin.dockwidget.setVisible(True) + self.plugin.switch_to_plugin() + QMainWindow.closeEvent(self, event) + if self.plugin._undocked_window is not None: + self.plugin._undocked_window = None + + def get_layout_settings(self): + """Return layout state""" + splitsettings = self.editorwidget.editorsplitter.get_layout_settings() + return dict(size=(self.window_size.width(), self.window_size.height()), + pos=(self.pos().x(), self.pos().y()), + is_maximized=self.isMaximized(), + is_fullscreen=self.isFullScreen(), + hexstate=qbytearray_to_str(self.saveState()), + splitsettings=splitsettings) + + def set_layout_settings(self, settings): + """Restore layout state""" + size = settings.get('size') + if size is not None: + self.resize( QSize(*size) ) + self.window_size = self.size() + pos = settings.get('pos') + if pos is not None: + self.move( QPoint(*pos) ) + hexstate = settings.get('hexstate') + if hexstate is not None: + self.restoreState( QByteArray().fromHex( + str(hexstate).encode('utf-8')) ) + if settings.get('is_maximized'): + self.setWindowState(Qt.WindowMaximized) + if settings.get('is_fullscreen'): + self.setWindowState(Qt.WindowFullScreen) + splitsettings = settings.get('splitsettings') + if splitsettings is not None: + self.editorwidget.editorsplitter.set_layout_settings(splitsettings) + + +class EditorPluginExample(QSplitter): + def __init__(self): + QSplitter.__init__(self) + + self._dock_action = None + self._undock_action = None + self._close_plugin_action = None + self._undocked_window = None + self._lock_unlock_action = None + menu_actions = [] + + self.editorstacks = [] + self.editorwindows = [] + + self.last_focused_editorstack = {} # fake + + self.find_widget = FindReplace(self, enable_replace=True) + self.outlineexplorer = OutlineExplorerWidget(None, self, self) + self.outlineexplorer.edit_goto.connect(self.go_to_file) + self.editor_splitter = EditorSplitter(self, self, menu_actions, + first=True) + + editor_widgets = QWidget(self) + editor_layout = QVBoxLayout() + editor_layout.setContentsMargins(0, 0, 0, 0) + editor_widgets.setLayout(editor_layout) + editor_layout.addWidget(self.editor_splitter) + editor_layout.addWidget(self.find_widget) + + self.setContentsMargins(0, 0, 0, 0) + self.addWidget(editor_widgets) + self.addWidget(self.outlineexplorer) + + self.setStretchFactor(0, 5) + self.setStretchFactor(1, 1) + + self.menu_actions = menu_actions + self.toolbar_list = None + self.menu_list = None + self.setup_window([], []) + + def go_to_file(self, fname, lineno, text='', start_column=None): + editorstack = self.editorstacks[0] + editorstack.set_current_filename(to_text_string(fname)) + editor = editorstack.get_current_editor() + editor.go_to_line(lineno, word=text, start_column=start_column) + + def closeEvent(self, event): + for win in self.editorwindows[:]: + win.close() + logger.debug("%d: %r" % (len(self.editorwindows), self.editorwindows)) + logger.debug("%d: %r" % (len(self.editorstacks), self.editorstacks)) + event.accept() + + def load(self, fname): + QApplication.processEvents() + editorstack = self.editorstacks[0] + editorstack.load(fname) + editorstack.analyze_script() + + def register_editorstack(self, editorstack): + logger.debug("FakePlugin.register_editorstack: %r" % editorstack) + self.editorstacks.append(editorstack) + if self.isAncestorOf(editorstack): + # editorstack is a child of the Editor plugin + editorstack.set_closable(len(self.editorstacks) > 1) + editorstack.set_outlineexplorer(self.outlineexplorer) + editorstack.set_find_widget(self.find_widget) + oe_btn = create_toolbutton(self) + editorstack.add_corner_widgets_to_tabbar([5, oe_btn]) + + action = QAction(self) + editorstack.set_io_actions(action, action, action, action) + font = QFont("Courier New") + font.setPointSize(10) + editorstack.set_default_font(font, color_scheme='Spyder') + + editorstack.sig_close_file.connect(self.close_file_in_all_editorstacks) + editorstack.file_saved.connect(self.file_saved_in_editorstack) + editorstack.file_renamed_in_data.connect( + self.file_renamed_in_data_in_editorstack) + editorstack.plugin_load.connect(self.load) + + def unregister_editorstack(self, editorstack): + logger.debug("FakePlugin.unregister_editorstack: %r" % editorstack) + self.editorstacks.pop(self.editorstacks.index(editorstack)) + + def clone_editorstack(self, editorstack): + editorstack.clone_from(self.editorstacks[0]) + + def setup_window(self, toolbar_list, menu_list): + self.toolbar_list = toolbar_list + self.menu_list = menu_list + + def create_new_window(self): + window = EditorMainWindow(self, self.menu_actions, + self.toolbar_list, self.menu_list, + show_fullpath=False, show_all_files=False, + group_cells=True, show_comments=True, + sort_files_alphabetically=False) + window.resize(self.size()) + window.show() + self.register_editorwindow(window) + window.destroyed.connect(lambda: self.unregister_editorwindow(window)) + + def register_editorwindow(self, window): + logger.debug("register_editorwindowQObject*: %r" % window) + self.editorwindows.append(window) + + def unregister_editorwindow(self, window): + logger.debug("unregister_editorwindow: %r" % window) + self.editorwindows.pop(self.editorwindows.index(window)) + + def get_focus_widget(self): + pass + + @Slot(str, str) + def close_file_in_all_editorstacks(self, editorstack_id_str, filename): + for editorstack in self.editorstacks: + if str(id(editorstack)) != editorstack_id_str: + editorstack.blockSignals(True) + index = editorstack.get_index_from_filename(filename) + editorstack.close_file(index, force=True) + editorstack.blockSignals(False) + + # This method is never called in this plugin example. It's here only + # to show how to use the file_saved signal (see above). + @Slot(str, str, str) + def file_saved_in_editorstack(self, editorstack_id_str, + original_filename, filename): + """A file was saved in editorstack, this notifies others""" + for editorstack in self.editorstacks: + if str(id(editorstack)) != editorstack_id_str: + editorstack.file_saved_in_other_editorstack(original_filename, + filename) + + # This method is never called in this plugin example. It's here only + # to show how to use the file_saved signal (see above). + @Slot(str, str, str) + def file_renamed_in_data_in_editorstack(self, editorstack_id_str, + original_filename, filename): + """A file was renamed in data in editorstack, this notifies others""" + for editorstack in self.editorstacks: + if str(id(editorstack)) != editorstack_id_str: + editorstack.rename_in_data(original_filename, filename) + + def register_widget_shortcuts(self, widget): + """Fake!""" + pass + + def get_color_scheme(self): + pass + + +def test(): + from spyder.utils.qthelpers import qapplication + from spyder.config.base import get_module_path + + spyder_dir = get_module_path('spyder') + app = qapplication(test_time=8) + + test = EditorPluginExample() + test.resize(900, 700) + test.show() + + import time + t0 = time.time() + test.load(osp.join(spyder_dir, "widgets", "collectionseditor.py")) + test.load(osp.join(spyder_dir, "plugins", "editor", "widgets", + "editor.py")) + test.load(osp.join(spyder_dir, "plugins", "explorer", "widgets", + 'explorer.py')) + test.load(osp.join(spyder_dir, "plugins", "editor", "widgets", + "codeeditor.py")) + print("Elapsed time: %.3f s" % (time.time()-t0)) # spyder: test-skip + + sys.exit(app.exec_()) + + +if __name__ == "__main__": + test() diff --git a/spyder/plugins/explorer/plugin.py b/spyder/plugins/explorer/plugin.py index 9a6a37ece72..b3570f21742 100644 --- a/spyder/plugins/explorer/plugin.py +++ b/spyder/plugins/explorer/plugin.py @@ -1,271 +1,271 @@ -# -*- coding: utf-8 -*- -# -# Copyright © Spyder Project Contributors -# Licensed under the terms of the MIT License -# (see spyder/__init__.py for details) - -"""Files and Directories Explorer Plugin""" - -# pylint: disable=C0103 -# pylint: disable=R0903 -# pylint: disable=R0911 -# pylint: disable=R0201 - -# Standard library imports -import os.path as osp - -# Third party imports -from qtpy.QtCore import Signal - -# Local imports -from spyder.api.translations import get_translation -from spyder.api.plugins import SpyderDockablePlugin, Plugins -from spyder.api.plugin_registration.decorators import ( - on_plugin_available, on_plugin_teardown) -from spyder.plugins.explorer.widgets.main_widget import ExplorerWidget -from spyder.plugins.explorer.confpage import ExplorerConfigPage - -# Localization -_ = get_translation('spyder') - - -class Explorer(SpyderDockablePlugin): - """File and Directories Explorer DockWidget.""" - - NAME = 'explorer' - REQUIRES = [Plugins.Preferences] - OPTIONAL = [Plugins.IPythonConsole, Plugins.Editor] - TABIFY = Plugins.VariableExplorer - WIDGET_CLASS = ExplorerWidget - CONF_SECTION = NAME - CONF_WIDGET_CLASS = ExplorerConfigPage - CONF_FILE = False - DISABLE_ACTIONS_WHEN_HIDDEN = False - - # --- Signals - # ------------------------------------------------------------------------ - sig_dir_opened = Signal(str) - """ - This signal is emitted to indicate a folder has been opened. - - Parameters - ---------- - directory: str - The opened path directory. - - Notes - ----- - This will update the current working directory. - """ - - sig_file_created = Signal(str) - """ - This signal is emitted to request creating a new file with Spyder. - - Parameters - ---------- - path: str - File path to run. - """ - - sig_file_removed = Signal(str) - """ - This signal is emitted when a file is removed. - - Parameters - ---------- - path: str - File path removed. - """ - - sig_file_renamed = Signal(str, str) - """ - This signal is emitted when a file is renamed. - - Parameters - ---------- - old_path: str - Old path for renamed file. - new_path: str - New path for renamed file. - """ - - sig_open_file_requested = Signal(str) - """ - This signal is emitted to request opening a new file with Spyder. - - Parameters - ---------- - path: str - File path to run. - """ - - sig_folder_removed = Signal(str) - """ - This signal is emitted when a folder is removed. - - Parameters - ---------- - path: str - Folder to remove. - """ - - sig_folder_renamed = Signal(str, str) - """ - This signal is emitted when a folder is renamed. - - Parameters - ---------- - old_path: str - Old path for renamed folder. - new_path: str - New path for renamed folder. - """ - - sig_interpreter_opened = Signal(str) - """ - This signal is emitted to request opening an interpreter with the given - path as working directory. - - Parameters - ---------- - path: str - Path to use as working directory of interpreter. - """ - - sig_module_created = Signal(str) - """ - This signal is emitted to indicate a module has been created. - - Parameters - ---------- - directory: str - The created path directory. - """ - - sig_run_requested = Signal(str) - """ - This signal is emitted to request running a file. - - Parameters - ---------- - path: str - File path to run. - """ - - # ---- SpyderDockablePlugin API - # ------------------------------------------------------------------------ - @staticmethod - def get_name(): - """Return widget title""" - return _("Files") - - def get_description(self): - """Return the description of the explorer widget.""" - return _("Explore files in the computer with a tree view.") - - def get_icon(self): - """Return the explorer icon.""" - # TODO: Find a decent icon for the explorer - return self.create_icon('outline_explorer') - - def on_initialize(self): - widget = self.get_widget() - - # Expose widget signals on the plugin - widget.sig_dir_opened.connect(self.sig_dir_opened) - widget.sig_file_created.connect(self.sig_file_created) - widget.sig_open_file_requested.connect(self.sig_open_file_requested) - widget.sig_open_interpreter_requested.connect( - self.sig_interpreter_opened) - widget.sig_module_created.connect(self.sig_module_created) - widget.sig_removed.connect(self.sig_file_removed) - widget.sig_renamed.connect(self.sig_file_renamed) - widget.sig_run_requested.connect(self.sig_run_requested) - widget.sig_tree_removed.connect(self.sig_folder_removed) - widget.sig_tree_renamed.connect(self.sig_folder_renamed) - - @on_plugin_available(plugin=Plugins.Editor) - def on_editor_available(self): - editor = self.get_plugin(Plugins.Editor) - - editor.sig_dir_opened.connect(self.chdir) - self.sig_file_created.connect(lambda t: editor.new(text=t)) - self.sig_file_removed.connect(editor.removed) - self.sig_file_renamed.connect(editor.renamed) - self.sig_folder_removed.connect(editor.removed_tree) - self.sig_folder_renamed.connect(editor.renamed_tree) - self.sig_module_created.connect(editor.new) - self.sig_open_file_requested.connect(editor.load) - - @on_plugin_available(plugin=Plugins.Preferences) - def on_preferences_available(self): - # Add preference config page - preferences = self.get_plugin(Plugins.Preferences) - preferences.register_plugin_preferences(self) - - @on_plugin_available(plugin=Plugins.IPythonConsole) - def on_ipython_console_available(self): - ipyconsole = self.get_plugin(Plugins.IPythonConsole) - self.sig_interpreter_opened.connect( - ipyconsole.create_client_from_path) - self.sig_run_requested.connect( - lambda fname: - ipyconsole.run_script(fname, osp.dirname(fname), '', False, - False, False, True, False)) - - @on_plugin_teardown(plugin=Plugins.Editor) - def on_editor_teardown(self): - editor = self.get_plugin(Plugins.Editor) - - editor.sig_dir_opened.disconnect(self.chdir) - self.sig_file_created.disconnect() - self.sig_file_removed.disconnect(editor.removed) - self.sig_file_renamed.disconnect(editor.renamed) - self.sig_folder_removed.disconnect(editor.removed_tree) - self.sig_folder_renamed.disconnect(editor.renamed_tree) - self.sig_module_created.disconnect(editor.new) - self.sig_open_file_requested.disconnect(editor.load) - - @on_plugin_teardown(plugin=Plugins.Preferences) - def on_preferences_teardown(self): - preferences = self.get_plugin(Plugins.Preferences) - preferences.deregister_plugin_preferences(self) - - @on_plugin_teardown(plugin=Plugins.IPythonConsole) - def on_ipython_console_teardown(self): - ipyconsole = self.get_plugin(Plugins.IPythonConsole) - self.sig_interpreter_opened.disconnect( - ipyconsole.create_client_from_path) - self.sig_run_requested.disconnect() - - # ---- Public API - # ------------------------------------------------------------------------ - def chdir(self, directory, emit=True): - """ - Set working directory. - - Parameters - ---------- - directory: str - The new working directory path. - emit: bool, optional - Emit a signal to indicate the working directory has changed. - Default is True. - """ - self.get_widget().chdir(directory, emit=emit) - - def refresh(self, new_path=None, force_current=True): - """ - Refresh history. - - Parameters - ---------- - new_path: str, optional - Path to add to history. Default is None. - force_current: bool, optional - Default is True. - """ - widget = self.get_widget() - widget.update_history(new_path) - widget.refresh(new_path, force_current=force_current) +# -*- coding: utf-8 -*- +# +# Copyright © Spyder Project Contributors +# Licensed under the terms of the MIT License +# (see spyder/__init__.py for details) + +"""Files and Directories Explorer Plugin""" + +# pylint: disable=C0103 +# pylint: disable=R0903 +# pylint: disable=R0911 +# pylint: disable=R0201 + +# Standard library imports +import os.path as osp + +# Third party imports +from qtpy.QtCore import Signal + +# Local imports +from spyder.api.translations import get_translation +from spyder.api.plugins import SpyderDockablePlugin, Plugins +from spyder.api.plugin_registration.decorators import ( + on_plugin_available, on_plugin_teardown) +from spyder.plugins.explorer.widgets.main_widget import ExplorerWidget +from spyder.plugins.explorer.confpage import ExplorerConfigPage + +# Localization +_ = get_translation('spyder') + + +class Explorer(SpyderDockablePlugin): + """File and Directories Explorer DockWidget.""" + + NAME = 'explorer' + REQUIRES = [Plugins.Preferences] + OPTIONAL = [Plugins.IPythonConsole, Plugins.Editor] + TABIFY = Plugins.VariableExplorer + WIDGET_CLASS = ExplorerWidget + CONF_SECTION = NAME + CONF_WIDGET_CLASS = ExplorerConfigPage + CONF_FILE = False + DISABLE_ACTIONS_WHEN_HIDDEN = False + + # --- Signals + # ------------------------------------------------------------------------ + sig_dir_opened = Signal(str) + """ + This signal is emitted to indicate a folder has been opened. + + Parameters + ---------- + directory: str + The opened path directory. + + Notes + ----- + This will update the current working directory. + """ + + sig_file_created = Signal(str) + """ + This signal is emitted to request creating a new file with Spyder. + + Parameters + ---------- + path: str + File path to run. + """ + + sig_file_removed = Signal(str) + """ + This signal is emitted when a file is removed. + + Parameters + ---------- + path: str + File path removed. + """ + + sig_file_renamed = Signal(str, str) + """ + This signal is emitted when a file is renamed. + + Parameters + ---------- + old_path: str + Old path for renamed file. + new_path: str + New path for renamed file. + """ + + sig_open_file_requested = Signal(str) + """ + This signal is emitted to request opening a new file with Spyder. + + Parameters + ---------- + path: str + File path to run. + """ + + sig_folder_removed = Signal(str) + """ + This signal is emitted when a folder is removed. + + Parameters + ---------- + path: str + Folder to remove. + """ + + sig_folder_renamed = Signal(str, str) + """ + This signal is emitted when a folder is renamed. + + Parameters + ---------- + old_path: str + Old path for renamed folder. + new_path: str + New path for renamed folder. + """ + + sig_interpreter_opened = Signal(str) + """ + This signal is emitted to request opening an interpreter with the given + path as working directory. + + Parameters + ---------- + path: str + Path to use as working directory of interpreter. + """ + + sig_module_created = Signal(str) + """ + This signal is emitted to indicate a module has been created. + + Parameters + ---------- + directory: str + The created path directory. + """ + + sig_run_requested = Signal(str) + """ + This signal is emitted to request running a file. + + Parameters + ---------- + path: str + File path to run. + """ + + # ---- SpyderDockablePlugin API + # ------------------------------------------------------------------------ + @staticmethod + def get_name(): + """Return widget title""" + return _("Files") + + def get_description(self): + """Return the description of the explorer widget.""" + return _("Explore files in the computer with a tree view.") + + def get_icon(self): + """Return the explorer icon.""" + # TODO: Find a decent icon for the explorer + return self.create_icon('outline_explorer') + + def on_initialize(self): + widget = self.get_widget() + + # Expose widget signals on the plugin + widget.sig_dir_opened.connect(self.sig_dir_opened) + widget.sig_file_created.connect(self.sig_file_created) + widget.sig_open_file_requested.connect(self.sig_open_file_requested) + widget.sig_open_interpreter_requested.connect( + self.sig_interpreter_opened) + widget.sig_module_created.connect(self.sig_module_created) + widget.sig_removed.connect(self.sig_file_removed) + widget.sig_renamed.connect(self.sig_file_renamed) + widget.sig_run_requested.connect(self.sig_run_requested) + widget.sig_tree_removed.connect(self.sig_folder_removed) + widget.sig_tree_renamed.connect(self.sig_folder_renamed) + + @on_plugin_available(plugin=Plugins.Editor) + def on_editor_available(self): + editor = self.get_plugin(Plugins.Editor) + + editor.sig_dir_opened.connect(self.chdir) + self.sig_file_created.connect(lambda t: editor.new(text=t)) + self.sig_file_removed.connect(editor.removed) + self.sig_file_renamed.connect(editor.renamed) + self.sig_folder_removed.connect(editor.removed_tree) + self.sig_folder_renamed.connect(editor.renamed_tree) + self.sig_module_created.connect(editor.new) + self.sig_open_file_requested.connect(editor.load) + + @on_plugin_available(plugin=Plugins.Preferences) + def on_preferences_available(self): + # Add preference config page + preferences = self.get_plugin(Plugins.Preferences) + preferences.register_plugin_preferences(self) + + @on_plugin_available(plugin=Plugins.IPythonConsole) + def on_ipython_console_available(self): + ipyconsole = self.get_plugin(Plugins.IPythonConsole) + self.sig_interpreter_opened.connect( + ipyconsole.create_client_from_path) + self.sig_run_requested.connect( + lambda fname: + ipyconsole.run_script(fname, osp.dirname(fname), '', False, + False, False, True, False)) + + @on_plugin_teardown(plugin=Plugins.Editor) + def on_editor_teardown(self): + editor = self.get_plugin(Plugins.Editor) + + editor.sig_dir_opened.disconnect(self.chdir) + self.sig_file_created.disconnect() + self.sig_file_removed.disconnect(editor.removed) + self.sig_file_renamed.disconnect(editor.renamed) + self.sig_folder_removed.disconnect(editor.removed_tree) + self.sig_folder_renamed.disconnect(editor.renamed_tree) + self.sig_module_created.disconnect(editor.new) + self.sig_open_file_requested.disconnect(editor.load) + + @on_plugin_teardown(plugin=Plugins.Preferences) + def on_preferences_teardown(self): + preferences = self.get_plugin(Plugins.Preferences) + preferences.deregister_plugin_preferences(self) + + @on_plugin_teardown(plugin=Plugins.IPythonConsole) + def on_ipython_console_teardown(self): + ipyconsole = self.get_plugin(Plugins.IPythonConsole) + self.sig_interpreter_opened.disconnect( + ipyconsole.create_client_from_path) + self.sig_run_requested.disconnect() + + # ---- Public API + # ------------------------------------------------------------------------ + def chdir(self, directory, emit=True): + """ + Set working directory. + + Parameters + ---------- + directory: str + The new working directory path. + emit: bool, optional + Emit a signal to indicate the working directory has changed. + Default is True. + """ + self.get_widget().chdir(directory, emit=emit) + + def refresh(self, new_path=None, force_current=True): + """ + Refresh history. + + Parameters + ---------- + new_path: str, optional + Path to add to history. Default is None. + force_current: bool, optional + Default is True. + """ + widget = self.get_widget() + widget.update_history(new_path) + widget.refresh(new_path, force_current=force_current) diff --git a/spyder/plugins/explorer/widgets/explorer.py b/spyder/plugins/explorer/widgets/explorer.py index 428b70c3064..cde17b99ee5 100644 --- a/spyder/plugins/explorer/widgets/explorer.py +++ b/spyder/plugins/explorer/widgets/explorer.py @@ -1,1938 +1,1938 @@ -# -*- coding: utf-8 -*- -# -# Copyright © Spyder Project Contributors -# Licensed under the terms of the MIT License -# (see spyder/__init__.py for details) - -"""Files and Directories Explorer""" - -# pylint: disable=C0103 -# pylint: disable=R0903 -# pylint: disable=R0911 -# pylint: disable=R0201 - -from __future__ import with_statement - -# Standard library imports -import os -import os.path as osp -import re -import shutil -import sys - -# Third party imports -from qtpy import PYQT5 -from qtpy.compat import getexistingdirectory, getsavefilename -from qtpy.QtCore import QDir, QMimeData, Qt, QTimer, QUrl, Signal, Slot -from qtpy.QtGui import QDrag -from qtpy.QtWidgets import (QApplication, QDialog, QDialogButtonBox, - QFileSystemModel, QInputDialog, QLabel, QLineEdit, - QMessageBox, QProxyStyle, QStyle, QTextEdit, - QToolTip, QTreeView, QVBoxLayout) - -# Local imports -from spyder.api.config.decorators import on_conf_change -from spyder.api.translations import get_translation -from spyder.api.widgets.mixins import SpyderWidgetMixin -from spyder.config.base import get_home_dir -from spyder.config.main import NAME_FILTERS -from spyder.plugins.explorer.widgets.utils import ( - create_script, fixpath, IconProvider, show_in_external_file_explorer) -from spyder.py3compat import to_binary_string -from spyder.utils import encoding -from spyder.utils.icon_manager import ima -from spyder.utils import misc, programs, vcs -from spyder.utils.misc import getcwd_or_home -from spyder.utils.qthelpers import file_uri, start_file - -try: - from nbconvert import PythonExporter as nbexporter -except: - nbexporter = None # analysis:ignore - - -# Localization -_ = get_translation('spyder') - - -# ---- Constants -# ---------------------------------------------------------------------------- -class DirViewColumns: - Size = 1 - Type = 2 - Date = 3 - - -class DirViewOpenWithSubMenuSections: - Main = 'Main' - - -class DirViewActions: - # Toggles - ToggleDateColumn = 'toggle_date_column_action' - ToggleSingleClick = 'toggle_single_click_to_open_action' - ToggleSizeColumn = 'toggle_size_column_action' - ToggleTypeColumn = 'toggle_type_column_action' - ToggleHiddenFiles = 'toggle_show_hidden_action' - - # Triggers - EditNameFilters = 'edit_name_filters_action' - NewFile = 'new_file_action' - NewModule = 'new_module_action' - NewFolder = 'new_folder_action' - NewPackage = 'new_package_action' - OpenWithSpyder = 'open_with_spyder_action' - OpenWithSystem = 'open_with_system_action' - OpenWithSystem2 = 'open_with_system_2_action' - Delete = 'delete_action' - Rename = 'rename_action' - Move = 'move_action' - Copy = 'copy_action' - Paste = 'paste_action' - CopyAbsolutePath = 'copy_absolute_path_action' - CopyRelativePath = 'copy_relative_path_action' - ShowInSystemExplorer = 'show_system_explorer_action' - VersionControlCommit = 'version_control_commit_action' - VersionControlBrowse = 'version_control_browse_action' - ConvertNotebook = 'convert_notebook_action' - - # TODO: Move this to the IPython Console - OpenInterpreter = 'open_interpreter_action' - Run = 'run_action' - - -class DirViewMenus: - Context = 'context_menu' - Header = 'header_menu' - New = 'new_menu' - OpenWith = 'open_with_menu' - - -class DirViewHeaderMenuSections: - Main = 'main_section' - - -class DirViewNewSubMenuSections: - General = 'general_section' - Language = 'language_section' - - -class DirViewContextMenuSections: - CopyPaste = 'copy_paste_section' - Extras = 'extras_section' - New = 'new_section' - System = 'system_section' - VersionControl = 'version_control_section' - - -class ExplorerTreeWidgetActions: - # Toggles - ToggleFilter = 'toggle_filter_files_action' - - # Triggers - Next = 'next_action' - Parent = 'parent_action' - Previous = 'previous_action' - - -# ---- Styles -# ---------------------------------------------------------------------------- -class DirViewStyle(QProxyStyle): - - def styleHint(self, hint, option=None, widget=None, return_data=None): - """ - To show tooltips with longer delays. - - From https://stackoverflow.com/a/59059919/438386 - """ - if hint == QStyle.SH_ToolTip_WakeUpDelay: - return 1000 # 1 sec - elif hint == QStyle.SH_ToolTip_FallAsleepDelay: - # This removes some flickering when showing tooltips - return 0 - - return super().styleHint(hint, option, widget, return_data) - - -# ---- Widgets -# ---------------------------------------------------------------------------- -class DirView(QTreeView, SpyderWidgetMixin): - """Base file/directory tree view.""" - - # Signals - sig_file_created = Signal(str) - """ - This signal is emitted when a file is created - - Parameters - ---------- - module: str - Path to the created file. - """ - - sig_open_interpreter_requested = Signal(str) - """ - This signal is emitted when the interpreter opened is requested - - Parameters - ---------- - module: str - Path to use as working directory of interpreter. - """ - - sig_module_created = Signal(str) - """ - This signal is emitted when a new python module is created. - - Parameters - ---------- - module: str - Path to the new module created. - """ - - sig_redirect_stdio_requested = Signal(bool) - """ - This signal is emitted when redirect stdio is requested. - - Parameters - ---------- - enable: bool - Enable/Disable standard input/output redirection. - """ - - sig_removed = Signal(str) - """ - This signal is emitted when a file is removed. - - Parameters - ---------- - path: str - File path removed. - """ - - sig_renamed = Signal(str, str) - """ - This signal is emitted when a file is renamed. - - Parameters - ---------- - old_path: str - Old path for renamed file. - new_path: str - New path for renamed file. - """ - - sig_run_requested = Signal(str) - """ - This signal is emitted to request running a file. - - Parameters - ---------- - path: str - File path to run. - """ - - sig_tree_removed = Signal(str) - """ - This signal is emitted when a folder is removed. - - Parameters - ---------- - path: str - Folder to remove. - """ - - sig_tree_renamed = Signal(str, str) - """ - This signal is emitted when a folder is renamed. - - Parameters - ---------- - old_path: str - Old path for renamed folder. - new_path: str - New path for renamed folder. - """ - - sig_open_file_requested = Signal(str) - """ - This signal is emitted to request opening a new file with Spyder. - - Parameters - ---------- - path: str - File path to run. - """ - - def __init__(self, parent=None): - """Initialize the DirView. - - Parameters - ---------- - parent: QWidget - Parent QWidget of the widget. - """ - if PYQT5: - super().__init__(parent=parent, class_parent=parent) - else: - QTreeView.__init__(self, parent) - SpyderWidgetMixin.__init__(self, class_parent=parent) - - # Attributes - self._parent = parent - self._last_column = 0 - self._last_order = True - self._scrollbar_positions = None - self._to_be_loaded = None - self.__expanded_state = None - self.common_actions = None - self.filter_on = False - self.expanded_or_colapsed_by_mouse = False - - # Widgets - self.fsmodel = None - self.menu = None - self.header_menu = None - header = self.header() - - # Signals - header.customContextMenuRequested.connect(self.show_header_menu) - - # Style adjustments - self._style = DirViewStyle(None) - self._style.setParent(self) - self.setStyle(self._style) - - # Setup - self.setup_fs_model() - self.setSelectionMode(self.ExtendedSelection) - header.setContextMenuPolicy(Qt.CustomContextMenu) - - # Track mouse movements. This activates the mouseMoveEvent declared - # below. - self.setMouseTracking(True) - - # ---- SpyderWidgetMixin API - # ------------------------------------------------------------------------ - def setup(self): - self.setup_view() - - # New actions - new_file_action = self.create_action( - DirViewActions.NewFile, - text=_("File..."), - icon=self.create_icon('TextFileIcon'), - triggered=lambda: self.new_file(), - ) - - new_module_action = self.create_action( - DirViewActions.NewModule, - text=_("Python file..."), - icon=self.create_icon('python'), - triggered=lambda: self.new_module(), - ) - - new_folder_action = self.create_action( - DirViewActions.NewFolder, - text=_("Folder..."), - icon=self.create_icon('folder_new'), - triggered=lambda: self.new_folder(), - ) - - new_package_action = self.create_action( - DirViewActions.NewPackage, - text=_("Python Package..."), - icon=self.create_icon('package_new'), - triggered=lambda: self.new_package(), - ) - - # Open actions - self.open_with_spyder_action = self.create_action( - DirViewActions.OpenWithSpyder, - text=_("Open in Spyder"), - icon=self.create_icon('edit'), - triggered=lambda: self.open(), - ) - - self.open_external_action = self.create_action( - DirViewActions.OpenWithSystem, - text=_("Open externally"), - triggered=lambda: self.open_external(), - ) - - self.open_external_action_2 = self.create_action( - DirViewActions.OpenWithSystem2, - text=_("Default external application"), - triggered=lambda: self.open_external(), - register_shortcut=False, - ) - - # File management actions - delete_action = self.create_action( - DirViewActions.Delete, - text=_("Delete..."), - icon=self.create_icon('editdelete'), - triggered=lambda: self.delete(), - ) - - rename_action = self.create_action( - DirViewActions.Rename, - text=_("Rename..."), - icon=self.create_icon('rename'), - triggered=lambda: self.rename(), - ) - - self.move_action = self.create_action( - DirViewActions.Move, - text=_("Move..."), - icon=self.create_icon('move'), - triggered=lambda: self.move(), - ) - - # Copy/Paste actions - copy_action = self.create_action( - DirViewActions.Copy, - text=_("Copy"), - icon=self.create_icon('editcopy'), - triggered=lambda: self.copy_file_clipboard(), - ) - - self.paste_action = self.create_action( - DirViewActions.Paste, - text=_("Paste"), - icon=self.create_icon('editpaste'), - triggered=lambda: self.save_file_clipboard(), - ) - - copy_absolute_path_action = self.create_action( - DirViewActions.CopyAbsolutePath, - text=_("Copy Absolute Path"), - triggered=lambda: self.copy_absolute_path(), - ) - - copy_relative_path_action = self.create_action( - DirViewActions.CopyRelativePath, - text=_("Copy Relative Path"), - triggered=lambda: self.copy_relative_path(), - ) - - # Show actions - if sys.platform == 'darwin': - show_in_finder_text = _("Show in Finder") - else: - show_in_finder_text = _("Show in Folder") - - show_in_system_explorer_action = self.create_action( - DirViewActions.ShowInSystemExplorer, - text=show_in_finder_text, - triggered=lambda: self.show_in_external_file_explorer(), - ) - - # Version control actions - self.vcs_commit_action = self.create_action( - DirViewActions.VersionControlCommit, - text=_("Commit"), - icon=self.create_icon('vcs_commit'), - triggered=lambda: self.vcs_command('commit'), - ) - self.vcs_log_action = self.create_action( - DirViewActions.VersionControlBrowse, - text=_("Browse repository"), - icon=self.create_icon('vcs_browse'), - triggered=lambda: self.vcs_command('browse'), - ) - - # Common actions - self.hidden_action = self.create_action( - DirViewActions.ToggleHiddenFiles, - text=_("Show hidden files"), - toggled=True, - initial=self.get_conf('show_hidden'), - option='show_hidden' - ) - - self.filters_action = self.create_action( - DirViewActions.EditNameFilters, - text=_("Edit filter settings..."), - icon=self.create_icon('filter'), - triggered=lambda: self.edit_filter(), - ) - - self.create_action( - DirViewActions.ToggleSingleClick, - text=_("Single click to open"), - toggled=True, - initial=self.get_conf('single_click_to_open'), - option='single_click_to_open' - ) - - # IPython console actions - # TODO: Move this option to the ipython console setup - self.open_interpreter_action = self.create_action( - DirViewActions.OpenInterpreter, - text=_("Open IPython console here"), - triggered=lambda: self.open_interpreter(), - ) - - # TODO: Move this option to the ipython console setup - run_action = self.create_action( - DirViewActions.Run, - text=_("Run"), - icon=self.create_icon('run'), - triggered=lambda: self.run(), - ) - - # Notebook Actions - ipynb_convert_action = self.create_action( - DirViewActions.ConvertNotebook, - _("Convert to Python file"), - icon=ima.icon('python'), - triggered=lambda: self.convert_notebooks() - ) - - # Header Actions - size_column_action = self.create_action( - DirViewActions.ToggleSizeColumn, - text=_('Size'), - toggled=True, - initial=self.get_conf('size_column'), - register_shortcut=False, - option='size_column' - ) - type_column_action = self.create_action( - DirViewActions.ToggleTypeColumn, - text=_('Type') if sys.platform == 'darwin' else _('Type'), - toggled=True, - initial=self.get_conf('type_column'), - register_shortcut=False, - option='type_column' - ) - date_column_action = self.create_action( - DirViewActions.ToggleDateColumn, - text=_("Date modified"), - toggled=True, - initial=self.get_conf('date_column'), - register_shortcut=False, - option='date_column' - ) - - # Header Context Menu - self.header_menu = self.create_menu(DirViewMenus.Header) - for item in [size_column_action, type_column_action, - date_column_action]: - self.add_item_to_menu( - item, - menu=self.header_menu, - section=DirViewHeaderMenuSections.Main, - ) - - # New submenu - new_submenu = self.create_menu( - DirViewMenus.New, - _('New'), - ) - for item in [new_file_action, new_folder_action]: - self.add_item_to_menu( - item, - menu=new_submenu, - section=DirViewNewSubMenuSections.General, - ) - - for item in [new_module_action, new_package_action]: - self.add_item_to_menu( - item, - menu=new_submenu, - section=DirViewNewSubMenuSections.Language, - ) - - # Open with submenu - self.open_with_submenu = self.create_menu( - DirViewMenus.OpenWith, - _('Open with'), - ) - - # Context submenu - self.context_menu = self.create_menu(DirViewMenus.Context) - for item in [new_submenu, run_action, - self.open_with_spyder_action, - self.open_with_submenu, - self.open_external_action, - delete_action, rename_action, self.move_action]: - self.add_item_to_menu( - item, - menu=self.context_menu, - section=DirViewContextMenuSections.New, - ) - - # Copy/Paste section - for item in [copy_action, self.paste_action, copy_absolute_path_action, - copy_relative_path_action]: - self.add_item_to_menu( - item, - menu=self.context_menu, - section=DirViewContextMenuSections.CopyPaste, - ) - - self.add_item_to_menu( - show_in_system_explorer_action, - menu=self.context_menu, - section=DirViewContextMenuSections.System, - ) - - # Version control section - for item in [self.vcs_commit_action, self.vcs_log_action]: - self.add_item_to_menu( - item, - menu=self.context_menu, - section=DirViewContextMenuSections.VersionControl - ) - - for item in [self.open_interpreter_action, ipynb_convert_action]: - self.add_item_to_menu( - item, - menu=self.context_menu, - section=DirViewContextMenuSections.Extras, - ) - - # Signals - self.context_menu.aboutToShow.connect(self.update_actions) - - @on_conf_change(option=['size_column', 'type_column', 'date_column', - 'name_filters', 'show_hidden', - 'single_click_to_open']) - def on_conf_update(self, option, value): - if option == 'size_column': - self.setColumnHidden(DirViewColumns.Size, not value) - elif option == 'type_column': - self.setColumnHidden(DirViewColumns.Type, not value) - elif option == 'date_column': - self.setColumnHidden(DirViewColumns.Date, not value) - elif option == 'name_filters': - if self.filter_on: - self.filter_files(value) - elif option == 'show_hidden': - self.set_show_hidden(value) - elif option == 'single_click_to_open': - self.set_single_click_to_open(value) - - def update_actions(self): - fnames = self.get_selected_filenames() - if fnames: - if osp.isdir(fnames[0]): - dirname = fnames[0] - else: - dirname = osp.dirname(fnames[0]) - - basedir = fixpath(osp.dirname(fnames[0])) - only_dirs = fnames and all([osp.isdir(fname) for fname in fnames]) - only_files = all([osp.isfile(fname) for fname in fnames]) - only_valid = all([encoding.is_text_file(fna) for fna in fnames]) - else: - only_files = False - only_valid = False - only_dirs = False - dirname = '' - basedir = '' - - vcs_visible = vcs.is_vcs_repository(dirname) - - # Make actions visible conditionally - self.move_action.setVisible( - all([fixpath(osp.dirname(fname)) == basedir for fname in fnames])) - self.open_external_action.setVisible(False) - self.open_interpreter_action.setVisible(only_dirs) - self.open_with_spyder_action.setVisible(only_files and only_valid) - self.open_with_submenu.menuAction().setVisible(False) - clipboard = QApplication.clipboard() - has_urls = clipboard.mimeData().hasUrls() - self.paste_action.setDisabled(not has_urls) - - # VCS support is quite limited for now, so we are enabling the VCS - # related actions only when a single file/folder is selected: - self.vcs_commit_action.setVisible(vcs_visible) - self.vcs_log_action.setVisible(vcs_visible) - - if only_files: - if len(fnames) == 1: - assoc = self.get_file_associations(fnames[0]) - elif len(fnames) > 1: - assoc = self.get_common_file_associations(fnames) - - if len(assoc) >= 1: - actions = self._create_file_associations_actions() - self.open_with_submenu.menuAction().setVisible(True) - self.open_with_submenu.clear_actions() - for action in actions: - self.add_item_to_menu( - action, - menu=self.open_with_submenu, - section=DirViewOpenWithSubMenuSections.Main, - ) - else: - self.open_external_action.setVisible(True) - - fnames = self.get_selected_filenames() - only_notebooks = all([osp.splitext(fname)[1] == '.ipynb' - for fname in fnames]) - only_modules = all([osp.splitext(fname)[1] in ('.py', '.pyw', '.ipy') - for fname in fnames]) - - nb_visible = only_notebooks and nbexporter is not None - self.get_action(DirViewActions.ConvertNotebook).setVisible(nb_visible) - self.get_action(DirViewActions.Run).setVisible(only_modules) - - def _create_file_associations_actions(self, fnames=None): - """ - Create file association actions. - """ - if fnames is None: - fnames = self.get_selected_filenames() - - actions = [] - only_files = all([osp.isfile(fname) for fname in fnames]) - if only_files: - if len(fnames) == 1: - assoc = self.get_file_associations(fnames[0]) - elif len(fnames) > 1: - assoc = self.get_common_file_associations(fnames) - - if len(assoc) >= 1: - for app_name, fpath in assoc: - text = app_name - if not (os.path.isfile(fpath) or os.path.isdir(fpath)): - text += _(' (Application not found!)') - - try: - # Action might have been created already - open_assoc = self.open_association - open_with_action = self.create_action( - app_name, - text=text, - triggered=lambda x, y=fpath: open_assoc(y), - register_shortcut=False, - ) - except Exception: - open_with_action = self.get_action(app_name) - - # Disconnect previous signal in case the app path - # changed - try: - open_with_action.triggered.disconnect() - except Exception: - pass - - # Reconnect the trigger signal - open_with_action.triggered.connect( - lambda x, y=fpath: self.open_association(y) - ) - - if not (os.path.isfile(fpath) or os.path.isdir(fpath)): - open_with_action.setDisabled(True) - - actions.append(open_with_action) - - actions.append(self.open_external_action_2) - - return actions - - # ---- Qt overrides - # ------------------------------------------------------------------------ - def sortByColumn(self, column, order=Qt.AscendingOrder): - """Override Qt method.""" - header = self.header() - header.setSortIndicatorShown(True) - QTreeView.sortByColumn(self, column, order) - header.setSortIndicator(0, order) - self._last_column = column - self._last_order = not self._last_order - - def viewportEvent(self, event): - """Reimplement Qt method""" - - # Prevent Qt from crashing or showing warnings like: - # "QSortFilterProxyModel: index from wrong model passed to - # mapFromSource", probably due to the fact that the file system model - # is being built. See spyder-ide/spyder#1250. - # - # This workaround was inspired by the following KDE bug: - # https://bugs.kde.org/show_bug.cgi?id=172198 - # - # Apparently, this is a bug from Qt itself. - self.executeDelayedItemsLayout() - - return QTreeView.viewportEvent(self, event) - - def contextMenuEvent(self, event): - """Override Qt method""" - # Needed to handle not initialized menu. - # See spyder-ide/spyder#6975 - try: - fnames = self.get_selected_filenames() - if len(fnames) != 0: - self.context_menu.popup(event.globalPos()) - except AttributeError: - pass - - def keyPressEvent(self, event): - """Reimplement Qt method""" - if event.key() in (Qt.Key_Enter, Qt.Key_Return): - self.clicked() - elif event.key() == Qt.Key_F2: - self.rename() - elif event.key() == Qt.Key_Delete: - self.delete() - elif event.key() == Qt.Key_Backspace: - self.go_to_parent_directory() - else: - QTreeView.keyPressEvent(self, event) - - def mouseDoubleClickEvent(self, event): - """Handle double clicks.""" - super().mouseDoubleClickEvent(event) - if not self.get_conf('single_click_to_open'): - self.clicked(index=self.indexAt(event.pos())) - - def mousePressEvent(self, event): - """ - Detect when a directory was expanded or collapsed by clicking - on its arrow. - - Taken from https://stackoverflow.com/a/13142586/438386 - """ - clicked_index = self.indexAt(event.pos()) - if clicked_index.isValid(): - vrect = self.visualRect(clicked_index) - item_identation = vrect.x() - self.visualRect(self.rootIndex()).x() - if event.pos().x() < item_identation: - self.expanded_or_colapsed_by_mouse = True - else: - self.expanded_or_colapsed_by_mouse = False - super().mousePressEvent(event) - - def mouseReleaseEvent(self, event): - """Handle single clicks.""" - super().mouseReleaseEvent(event) - if self.get_conf('single_click_to_open'): - self.clicked(index=self.indexAt(event.pos())) - - def mouseMoveEvent(self, event): - """Actions to take with mouse movements.""" - # To hide previous tooltip - QToolTip.hideText() - - index = self.indexAt(event.pos()) - if index.isValid(): - if self.get_conf('single_click_to_open'): - vrect = self.visualRect(index) - item_identation = ( - vrect.x() - self.visualRect(self.rootIndex()).x() - ) - - if event.pos().x() > item_identation: - # When hovering over directories or files - self.setCursor(Qt.PointingHandCursor) - else: - # On every other element - self.setCursor(Qt.ArrowCursor) - - self.setToolTip(self.get_filename(index)) - - super().mouseMoveEvent(event) - - def dragEnterEvent(self, event): - """Drag and Drop - Enter event""" - event.setAccepted(event.mimeData().hasFormat("text/plain")) - - def dragMoveEvent(self, event): - """Drag and Drop - Move event""" - if (event.mimeData().hasFormat("text/plain")): - event.setDropAction(Qt.MoveAction) - event.accept() - else: - event.ignore() - - def startDrag(self, dropActions): - """Reimplement Qt Method - handle drag event""" - data = QMimeData() - data.setUrls([QUrl(fname) for fname in self.get_selected_filenames()]) - drag = QDrag(self) - drag.setMimeData(data) - drag.exec_() - - # ---- Model - # ------------------------------------------------------------------------ - def setup_fs_model(self): - """Setup filesystem model""" - self.fsmodel = QFileSystemModel(self) - self.fsmodel.setNameFilterDisables(False) - - def install_model(self): - """Install filesystem model""" - self.setModel(self.fsmodel) - - def setup_view(self): - """Setup view""" - self.install_model() - self.fsmodel.directoryLoaded.connect( - lambda: self.resizeColumnToContents(0)) - self.setAnimated(False) - self.setSortingEnabled(True) - self.sortByColumn(0, Qt.AscendingOrder) - self.fsmodel.modelReset.connect(self.reset_icon_provider) - self.reset_icon_provider() - - # ---- File/Dir Helpers - # ------------------------------------------------------------------------ - def get_filename(self, index): - """Return filename associated with *index*""" - if index: - return osp.normpath(str(self.fsmodel.filePath(index))) - - def get_index(self, filename): - """Return index associated with filename""" - return self.fsmodel.index(filename) - - def get_selected_filenames(self): - """Return selected filenames""" - fnames = [] - if self.selectionMode() == self.ExtendedSelection: - if self.selectionModel() is not None: - fnames = [self.get_filename(idx) for idx in - self.selectionModel().selectedRows()] - else: - fnames = [self.get_filename(self.currentIndex())] - - return fnames - - def get_dirname(self, index): - """Return dirname associated with *index*""" - fname = self.get_filename(index) - if fname: - if osp.isdir(fname): - return fname - else: - return osp.dirname(fname) - - # ---- General actions API - # ------------------------------------------------------------------------ - def show_header_menu(self, pos): - """Display header menu.""" - self.header_menu.popup(self.mapToGlobal(pos)) - - def clicked(self, index=None): - """ - Selected item was single/double-clicked or enter/return was pressed. - """ - fnames = self.get_selected_filenames() - - # Don't do anything when clicking on the arrow next to a directory - # to expand/collapse it. If clicking on its name, use it as `fnames`. - if index and index.isValid(): - fname = self.get_filename(index) - if osp.isdir(fname): - if self.expanded_or_colapsed_by_mouse: - return - else: - fnames = [fname] - - # Open files or directories - for fname in fnames: - if osp.isdir(fname): - self.directory_clicked(fname, index) - else: - if len(fnames) == 1: - assoc = self.get_file_associations(fnames[0]) - elif len(fnames) > 1: - assoc = self.get_common_file_associations(fnames) - - if assoc: - self.open_association(assoc[0][-1]) - else: - self.open([fname]) - - def directory_clicked(self, dirname, index): - """ - Handle directories being clicked. - - Parameters - ---------- - dirname: str - Path to the clicked directory. - index: QModelIndex - Index of the directory. - """ - raise NotImplementedError('To be implemented by subclasses') - - @Slot() - def edit_filter(self): - """Edit name filters.""" - # Create Dialog - dialog = QDialog(self) - dialog.resize(500, 300) - dialog.setWindowTitle(_('Edit filter settings')) - - # Create dialog contents - description_label = QLabel( - _('Filter files by name, extension, or more using ' - 'glob' - ' patterns. Please enter the glob patterns of the files you ' - 'want to show, separated by commas.')) - description_label.setOpenExternalLinks(True) - description_label.setWordWrap(True) - filters = QTextEdit(", ".join(self.get_conf('name_filters')), - parent=self) - layout = QVBoxLayout() - layout.addWidget(description_label) - layout.addWidget(filters) - - def handle_ok(): - filter_text = filters.toPlainText() - filter_text = [f.strip() for f in str(filter_text).split(',')] - self.set_name_filters(filter_text) - dialog.accept() - - def handle_reset(): - self.set_name_filters(NAME_FILTERS) - filters.setPlainText(", ".join(self.get_conf('name_filters'))) - - # Dialog buttons - button_box = QDialogButtonBox(QDialogButtonBox.Reset | - QDialogButtonBox.Ok | - QDialogButtonBox.Cancel) - button_box.accepted.connect(handle_ok) - button_box.rejected.connect(dialog.reject) - button_box.button(QDialogButtonBox.Reset).clicked.connect(handle_reset) - layout.addWidget(button_box) - dialog.setLayout(layout) - dialog.show() - - @Slot() - def open(self, fnames=None): - """Open files with the appropriate application""" - if fnames is None: - fnames = self.get_selected_filenames() - for fname in fnames: - if osp.isfile(fname) and encoding.is_text_file(fname): - self.sig_open_file_requested.emit(fname) - else: - self.open_outside_spyder([fname]) - - @Slot() - def open_association(self, app_path): - """Open files with given application executable path.""" - if not (os.path.isdir(app_path) or os.path.isfile(app_path)): - return_codes = {app_path: 1} - app_path = None - else: - return_codes = {} - - if app_path: - fnames = self.get_selected_filenames() - return_codes = programs.open_files_with_application(app_path, - fnames) - self.check_launch_error_codes(return_codes) - - @Slot() - def open_external(self, fnames=None): - """Open files with default application""" - if fnames is None: - fnames = self.get_selected_filenames() - for fname in fnames: - self.open_outside_spyder([fname]) - - def open_outside_spyder(self, fnames): - """ - Open file outside Spyder with the appropriate application. - - If this does not work, opening unknown file in Spyder, as text file. - """ - for path in sorted(fnames): - path = file_uri(path) - ok = start_file(path) - if not ok and encoding.is_text_file(path): - self.sig_open_file_requested.emit(path) - - def remove_tree(self, dirname): - """ - Remove whole directory tree - - Reimplemented in project explorer widget - """ - while osp.exists(dirname): - try: - shutil.rmtree(dirname, onerror=misc.onerror) - except Exception as e: - # This handles a Windows problem with shutil.rmtree. - # See spyder-ide/spyder#8567. - if type(e).__name__ == "OSError": - error_path = str(e.filename) - shutil.rmtree(error_path, ignore_errors=True) - - def delete_file(self, fname, multiple, yes_to_all): - """Delete file""" - if multiple: - buttons = (QMessageBox.Yes | QMessageBox.YesToAll | - QMessageBox.No | QMessageBox.Cancel) - else: - buttons = QMessageBox.Yes | QMessageBox.No - if yes_to_all is None: - answer = QMessageBox.warning( - self, _("Delete"), - _("Do you really want to delete %s?" - ) % osp.basename(fname), buttons) - if answer == QMessageBox.No: - return yes_to_all - elif answer == QMessageBox.Cancel: - return False - elif answer == QMessageBox.YesToAll: - yes_to_all = True - try: - if osp.isfile(fname): - misc.remove_file(fname) - self.sig_removed.emit(fname) - else: - self.remove_tree(fname) - self.sig_tree_removed.emit(fname) - return yes_to_all - except EnvironmentError as error: - action_str = _('delete') - QMessageBox.critical( - self, _("Project Explorer"), - _("Unable to %s %s

Error message:
%s" - ) % (action_str, fname, str(error))) - return False - - @Slot() - def delete(self, fnames=None): - """Delete files""" - if fnames is None: - fnames = self.get_selected_filenames() - multiple = len(fnames) > 1 - yes_to_all = None - for fname in fnames: - spyproject_path = osp.join(fname, '.spyproject') - if osp.isdir(fname) and osp.exists(spyproject_path): - QMessageBox.information( - self, _('File Explorer'), - _("The current directory contains a project.

" - "If you want to delete the project, please go to " - "Projects » Delete Project")) - else: - yes_to_all = self.delete_file(fname, multiple, yes_to_all) - if yes_to_all is not None and not yes_to_all: - # Canceled - break - - def rename_file(self, fname): - """Rename file""" - path, valid = QInputDialog.getText( - self, _('Rename'), _('New name:'), QLineEdit.Normal, - osp.basename(fname)) - - if valid: - path = osp.join(osp.dirname(fname), str(path)) - if path == fname: - return - if osp.exists(path): - answer = QMessageBox.warning( - self, _("Rename"), - _("Do you really want to rename %s and " - "overwrite the existing file %s?" - ) % (osp.basename(fname), osp.basename(path)), - QMessageBox.Yes | QMessageBox.No) - if answer == QMessageBox.No: - return - try: - misc.rename_file(fname, path) - if osp.isfile(path): - self.sig_renamed.emit(fname, path) - else: - self.sig_tree_renamed.emit(fname, path) - return path - except EnvironmentError as error: - QMessageBox.critical( - self, _("Rename"), - _("Unable to rename file %s" - "

Error message:
%s" - ) % (osp.basename(fname), str(error))) - - @Slot() - def show_in_external_file_explorer(self, fnames=None): - """Show file in external file explorer""" - if fnames is None: - fnames = self.get_selected_filenames() - show_in_external_file_explorer(fnames) - - @Slot() - def rename(self, fnames=None): - """Rename files""" - if fnames is None: - fnames = self.get_selected_filenames() - if not isinstance(fnames, (tuple, list)): - fnames = [fnames] - for fname in fnames: - self.rename_file(fname) - - @Slot() - def move(self, fnames=None, directory=None): - """Move files/directories""" - if fnames is None: - fnames = self.get_selected_filenames() - orig = fixpath(osp.dirname(fnames[0])) - while True: - self.sig_redirect_stdio_requested.emit(False) - if directory is None: - folder = getexistingdirectory( - self, _("Select directory"), orig) - else: - folder = directory - self.sig_redirect_stdio_requested.emit(True) - if folder: - folder = fixpath(folder) - if folder != orig: - break - else: - return - for fname in fnames: - basename = osp.basename(fname) - try: - misc.move_file(fname, osp.join(folder, basename)) - except EnvironmentError as error: - QMessageBox.critical( - self, _("Error"), - _("Unable to move %s" - "

Error message:
%s" - ) % (basename, str(error))) - - def create_new_folder(self, current_path, title, subtitle, is_package): - """Create new folder""" - if current_path is None: - current_path = '' - if osp.isfile(current_path): - current_path = osp.dirname(current_path) - name, valid = QInputDialog.getText(self, title, subtitle, - QLineEdit.Normal, "") - if valid: - dirname = osp.join(current_path, str(name)) - try: - os.mkdir(dirname) - except EnvironmentError as error: - QMessageBox.critical( - self, title, - _("Unable to create folder %s" - "

Error message:
%s" - ) % (dirname, str(error))) - finally: - if is_package: - fname = osp.join(dirname, '__init__.py') - try: - with open(fname, 'wb') as f: - f.write(to_binary_string('#')) - return dirname - except EnvironmentError as error: - QMessageBox.critical( - self, title, - _("Unable to create file %s" - "

Error message:
%s" - ) % (fname, str(error))) - - def get_selected_dir(self): - """ Get selected dir - If file is selected the directory containing file is returned. - If multiple items are selected, first item is chosen. - """ - selected_path = self.get_selected_filenames()[0] - if osp.isfile(selected_path): - selected_path = osp.dirname(selected_path) - return fixpath(selected_path) - - def new_folder(self, basedir=None): - """New folder.""" - - if basedir is None: - basedir = self.get_selected_dir() - - title = _('New folder') - subtitle = _('Folder name:') - self.create_new_folder(basedir, title, subtitle, is_package=False) - - def create_new_file(self, current_path, title, filters, create_func): - """Create new file - Returns True if successful""" - if current_path is None: - current_path = '' - if osp.isfile(current_path): - current_path = osp.dirname(current_path) - self.sig_redirect_stdio_requested.emit(False) - fname, _selfilter = getsavefilename(self, title, current_path, filters) - self.sig_redirect_stdio_requested.emit(True) - if fname: - try: - create_func(fname) - return fname - except EnvironmentError as error: - QMessageBox.critical( - self, _("New file"), - _("Unable to create file %s" - "

Error message:
%s" - ) % (fname, str(error))) - - def new_file(self, basedir=None): - """New file""" - - if basedir is None: - basedir = self.get_selected_dir() - - title = _("New file") - filters = _("All files")+" (*)" - - def create_func(fname): - """File creation callback""" - if osp.splitext(fname)[1] in ('.py', '.pyw', '.ipy'): - create_script(fname) - else: - with open(fname, 'wb') as f: - f.write(to_binary_string('')) - fname = self.create_new_file(basedir, title, filters, create_func) - if fname is not None: - self.open([fname]) - - @Slot() - def run(self, fnames=None): - """Run Python scripts""" - if fnames is None: - fnames = self.get_selected_filenames() - for fname in fnames: - self.sig_run_requested.emit(fname) - - def copy_path(self, fnames=None, method="absolute"): - """Copy absolute or relative path to given file(s)/folders(s).""" - cb = QApplication.clipboard() - explorer_dir = self.fsmodel.rootPath() - if fnames is None: - fnames = self.get_selected_filenames() - if not isinstance(fnames, (tuple, list)): - fnames = [fnames] - fnames = [_fn.replace(os.sep, "/") for _fn in fnames] - if len(fnames) > 1: - if method == "absolute": - clipboard_files = ',\n'.join('"' + _fn + '"' for _fn in fnames) - elif method == "relative": - clipboard_files = ',\n'.join('"' + - osp.relpath(_fn, explorer_dir). - replace(os.sep, "/") + '"' - for _fn in fnames) - else: - if method == "absolute": - clipboard_files = fnames[0] - elif method == "relative": - clipboard_files = (osp.relpath(fnames[0], explorer_dir). - replace(os.sep, "/")) - copied_from = self._parent.__class__.__name__ - if copied_from == 'ProjectExplorerWidget' and method == 'relative': - clipboard_files = [path.strip(',"') for path in - clipboard_files.splitlines()] - clipboard_files = ['/'.join(path.strip('/').split('/')[1:]) for - path in clipboard_files] - if len(clipboard_files) > 1: - clipboard_files = ',\n'.join('"' + _fn + '"' for _fn in - clipboard_files) - else: - clipboard_files = clipboard_files[0] - cb.setText(clipboard_files, mode=cb.Clipboard) - - @Slot() - def copy_absolute_path(self): - """Copy absolute paths of named files/directories to the clipboard.""" - self.copy_path(method="absolute") - - @Slot() - def copy_relative_path(self): - """Copy relative paths of named files/directories to the clipboard.""" - self.copy_path(method="relative") - - @Slot() - def copy_file_clipboard(self, fnames=None): - """Copy file(s)/folders(s) to clipboard.""" - if fnames is None: - fnames = self.get_selected_filenames() - if not isinstance(fnames, (tuple, list)): - fnames = [fnames] - try: - file_content = QMimeData() - file_content.setUrls([QUrl.fromLocalFile(_fn) for _fn in fnames]) - cb = QApplication.clipboard() - cb.setMimeData(file_content, mode=cb.Clipboard) - except Exception as e: - QMessageBox.critical( - self, _('File/Folder copy error'), - _("Cannot copy this type of file(s) or " - "folder(s). The error was:\n\n") + str(e)) - - @Slot() - def save_file_clipboard(self, fnames=None): - """Paste file from clipboard into file/project explorer directory.""" - if fnames is None: - fnames = self.get_selected_filenames() - if not isinstance(fnames, (tuple, list)): - fnames = [fnames] - if len(fnames) >= 1: - try: - selected_item = osp.commonpath(fnames) - except AttributeError: - # py2 does not have commonpath - if len(fnames) > 1: - selected_item = osp.normpath( - osp.dirname(osp.commonprefix(fnames))) - else: - selected_item = fnames[0] - if osp.isfile(selected_item): - parent_path = osp.dirname(selected_item) - else: - parent_path = osp.normpath(selected_item) - cb_data = QApplication.clipboard().mimeData() - if cb_data.hasUrls(): - urls = cb_data.urls() - for url in urls: - source_name = url.toLocalFile() - base_name = osp.basename(source_name) - if osp.isfile(source_name): - try: - while base_name in os.listdir(parent_path): - file_no_ext, file_ext = osp.splitext(base_name) - end_number = re.search(r'\d+$', file_no_ext) - if end_number: - new_number = int(end_number.group()) + 1 - else: - new_number = 1 - left_string = re.sub(r'\d+$', '', file_no_ext) - left_string += str(new_number) - base_name = left_string + file_ext - destination = osp.join(parent_path, base_name) - else: - destination = osp.join(parent_path, base_name) - shutil.copy(source_name, destination) - except Exception as e: - QMessageBox.critical(self, _('Error pasting file'), - _("Unsupported copy operation" - ". The error was:\n\n") - + str(e)) - else: - try: - while base_name in os.listdir(parent_path): - end_number = re.search(r'\d+$', base_name) - if end_number: - new_number = int(end_number.group()) + 1 - else: - new_number = 1 - left_string = re.sub(r'\d+$', '', base_name) - base_name = left_string + str(new_number) - destination = osp.join(parent_path, base_name) - else: - destination = osp.join(parent_path, base_name) - if osp.realpath(destination).startswith( - osp.realpath(source_name) + os.sep): - QMessageBox.critical(self, - _('Recursive copy'), - _("Source is an ancestor" - " of destination" - " folder.")) - continue - shutil.copytree(source_name, destination) - except Exception as e: - QMessageBox.critical(self, - _('Error pasting folder'), - _("Unsupported copy" - " operation. The error was:" - "\n\n") + str(e)) - else: - QMessageBox.critical(self, _("No file in clipboard"), - _("No file in the clipboard. Please copy" - " a file to the clipboard first.")) - else: - if QApplication.clipboard().mimeData().hasUrls(): - QMessageBox.critical(self, _('Blank area'), - _("Cannot paste in the blank area.")) - else: - pass - - def open_interpreter(self, fnames=None): - """Open interpreter""" - if fnames is None: - fnames = self.get_selected_filenames() - for path in sorted(fnames): - self.sig_open_interpreter_requested.emit(path) - - def filter_files(self, name_filters=None): - """Filter files given the defined list of filters.""" - if name_filters is None: - name_filters = self.get_conf('name_filters') - - if self.filter_on: - self.fsmodel.setNameFilters(name_filters) - else: - self.fsmodel.setNameFilters([]) - - # ---- File Associations - # ------------------------------------------------------------------------ - def get_common_file_associations(self, fnames): - """ - Return the list of common matching file associations for all fnames. - """ - all_values = [] - for fname in fnames: - values = self.get_file_associations(fname) - all_values.append(values) - - common = set(all_values[0]) - for index in range(1, len(all_values)): - common = common.intersection(all_values[index]) - return list(sorted(common)) - - def get_file_associations(self, fname): - """Return the list of matching file associations for `fname`.""" - for exts, values in self.get_conf('file_associations', {}).items(): - clean_exts = [ext.strip() for ext in exts.split(',')] - for ext in clean_exts: - if fname.endswith((ext, ext[1:])): - values = values - break - else: - continue # Only excecuted if the inner loop did not break - break # Only excecuted if the inner loop did break - else: - values = [] - - return values - - # ---- File/Directory actions - # ------------------------------------------------------------------------ - def check_launch_error_codes(self, return_codes): - """Check return codes and display message box if errors found.""" - errors = [cmd for cmd, code in return_codes.items() if code != 0] - if errors: - if len(errors) == 1: - msg = _('The following command did not launch successfully:') - else: - msg = _('The following commands did not launch successfully:') - - msg += '

' if len(errors) == 1 else '

    ' - for error in errors: - if len(errors) == 1: - msg += '{}'.format(error) - else: - msg += '
  • {}
  • '.format(error) - msg += '' if len(errors) == 1 else '
' - - QMessageBox.warning(self, 'Application', msg, QMessageBox.Ok) - - return not bool(errors) - - # ---- VCS actions - # ------------------------------------------------------------------------ - def vcs_command(self, action): - """VCS action (commit, browse)""" - fnames = self.get_selected_filenames() - - # Get dirname of selection - if osp.isdir(fnames[0]): - dirname = fnames[0] - else: - dirname = osp.dirname(fnames[0]) - - # Run action - try: - for path in sorted(fnames): - vcs.run_vcs_tool(dirname, action) - except vcs.ActionToolNotFound as error: - msg = _("For %s support, please install one of the
" - "following tools:

%s")\ - % (error.vcsname, ', '.join(error.tools)) - QMessageBox.critical( - self, _("Error"), - _("""Unable to find external program.

%s""" - ) % str(msg)) - - # ---- Settings - # ------------------------------------------------------------------------ - def get_scrollbar_position(self): - """Return scrollbar positions""" - return (self.horizontalScrollBar().value(), - self.verticalScrollBar().value()) - - def set_scrollbar_position(self, position): - """Set scrollbar positions""" - # Scrollbars will be restored after the expanded state - self._scrollbar_positions = position - if self._to_be_loaded is not None and len(self._to_be_loaded) == 0: - self.restore_scrollbar_positions() - - def restore_scrollbar_positions(self): - """Restore scrollbar positions once tree is loaded""" - hor, ver = self._scrollbar_positions - self.horizontalScrollBar().setValue(hor) - self.verticalScrollBar().setValue(ver) - - def get_expanded_state(self): - """Return expanded state""" - self.save_expanded_state() - return self.__expanded_state - - def set_expanded_state(self, state): - """Set expanded state""" - self.__expanded_state = state - self.restore_expanded_state() - - def save_expanded_state(self): - """Save all items expanded state""" - model = self.model() - # If model is not installed, 'model' will be None: this happens when - # using the Project Explorer without having selected a workspace yet - if model is not None: - self.__expanded_state = [] - for idx in model.persistentIndexList(): - if self.isExpanded(idx): - self.__expanded_state.append(self.get_filename(idx)) - - def restore_directory_state(self, fname): - """Restore directory expanded state""" - root = osp.normpath(str(fname)) - if not osp.exists(root): - # Directory has been (re)moved outside Spyder - return - for basename in os.listdir(root): - path = osp.normpath(osp.join(root, basename)) - if osp.isdir(path) and path in self.__expanded_state: - self.__expanded_state.pop(self.__expanded_state.index(path)) - if self._to_be_loaded is None: - self._to_be_loaded = [] - self._to_be_loaded.append(path) - self.setExpanded(self.get_index(path), True) - if not self.__expanded_state: - self.fsmodel.directoryLoaded.disconnect( - self.restore_directory_state) - - def follow_directories_loaded(self, fname): - """Follow directories loaded during startup""" - if self._to_be_loaded is None: - return - path = osp.normpath(str(fname)) - if path in self._to_be_loaded: - self._to_be_loaded.remove(path) - if self._to_be_loaded is not None and len(self._to_be_loaded) == 0: - self.fsmodel.directoryLoaded.disconnect( - self.follow_directories_loaded) - if self._scrollbar_positions is not None: - # The tree view need some time to render branches: - QTimer.singleShot(50, self.restore_scrollbar_positions) - - def restore_expanded_state(self): - """Restore all items expanded state""" - if self.__expanded_state is not None: - # In the old project explorer, the expanded state was a - # dictionary: - if isinstance(self.__expanded_state, list): - self.fsmodel.directoryLoaded.connect( - self.restore_directory_state) - self.fsmodel.directoryLoaded.connect( - self.follow_directories_loaded) - - # ---- Options - # ------------------------------------------------------------------------ - def set_single_click_to_open(self, value): - """Set single click to open items.""" - # Reset cursor shape - if not value: - self.unsetCursor() - - def set_file_associations(self, value): - """Set file associations open items.""" - self.set_conf('file_associations', value) - - def set_name_filters(self, name_filters): - """Set name filters""" - if self.get_conf('name_filters') == ['']: - self.set_conf('name_filters', []) - else: - self.set_conf('name_filters', name_filters) - - def set_show_hidden(self, state): - """Toggle 'show hidden files' state""" - filters = (QDir.AllDirs | QDir.Files | QDir.Drives | - QDir.NoDotAndDotDot) - if state: - filters = (QDir.AllDirs | QDir.Files | QDir.Drives | - QDir.NoDotAndDotDot | QDir.Hidden) - self.fsmodel.setFilter(filters) - - def reset_icon_provider(self): - """Reset file system model icon provider - The purpose of this is to refresh files/directories icons""" - self.fsmodel.setIconProvider(IconProvider(self)) - - def convert_notebook(self, fname): - """Convert an IPython notebook to a Python script in editor""" - try: - script = nbexporter().from_filename(fname)[0] - except Exception as e: - QMessageBox.critical( - self, _('Conversion error'), - _("It was not possible to convert this " - "notebook. The error is:\n\n") + str(e)) - return - self.sig_file_created.emit(script) - - def convert_notebooks(self): - """Convert IPython notebooks to Python scripts in editor""" - fnames = self.get_selected_filenames() - if not isinstance(fnames, (tuple, list)): - fnames = [fnames] - for fname in fnames: - self.convert_notebook(fname) - - def new_package(self, basedir=None): - """New package""" - - if basedir is None: - basedir = self.get_selected_dir() - - title = _('New package') - subtitle = _('Package name:') - self.create_new_folder(basedir, title, subtitle, is_package=True) - - def new_module(self, basedir=None): - """New module""" - - if basedir is None: - basedir = self.get_selected_dir() - - title = _("New module") - filters = _("Python files")+" (*.py *.pyw *.ipy)" - - def create_func(fname): - self.sig_module_created.emit(fname) - - self.create_new_file(basedir, title, filters, create_func) - - def go_to_parent_directory(self): - pass - - -class ExplorerTreeWidget(DirView): - """ - File/directory explorer tree widget. - """ - - sig_dir_opened = Signal(str) - """ - This signal is emitted when the current directory of the explorer tree - has changed. - - Parameters - ---------- - new_root_directory: str - The new root directory path. - - Notes - ----- - This happens when clicking (or double clicking depending on the option) - a folder, turning this folder in the new root parent of the tree. - """ - - def __init__(self, parent=None): - """Initialize the widget. - - Parameters - ---------- - parent: PluginMainWidget, optional - Parent widget of the explorer tree widget. - """ - super().__init__(parent=parent) - - # Attributes - self._parent = parent - self.__last_folder = None - self.__original_root_index = None - self.history = [] - self.histindex = None - - # Enable drag events - self.setDragEnabled(True) - - # ---- SpyderWidgetMixin API - # ------------------------------------------------------------------------ - def setup(self): - """ - Perform the setup of the widget. - """ - super().setup() - - # Actions - self.previous_action = self.create_action( - ExplorerTreeWidgetActions.Previous, - text=_("Previous"), - icon=self.create_icon('previous'), - triggered=self.go_to_previous_directory, - ) - self.next_action = self.create_action( - ExplorerTreeWidgetActions.Next, - text=_("Next"), - icon=self.create_icon('next'), - triggered=self.go_to_next_directory, - ) - self.create_action( - ExplorerTreeWidgetActions.Parent, - text=_("Parent"), - icon=self.create_icon('up'), - triggered=self.go_to_parent_directory - ) - - # Toolbuttons - self.filter_button = self.create_action( - ExplorerTreeWidgetActions.ToggleFilter, - text="", - icon=ima.icon('filter'), - toggled=self.change_filter_state - ) - self.filter_button.setCheckable(True) - - def update_actions(self): - """Update the widget actions.""" - super().update_actions() - - # ---- API - # ------------------------------------------------------------------------ - def change_filter_state(self): - """Handle the change of the filter state.""" - self.filter_on = not self.filter_on - self.filter_button.setChecked(self.filter_on) - self.filter_button.setToolTip(_("Filter filenames")) - self.filter_files() - - # ---- Refreshing widget - def set_current_folder(self, folder): - """ - Set current folder and return associated model index - - Parameters - ---------- - folder: str - New path to the selected folder. - """ - index = self.fsmodel.setRootPath(folder) - self.__last_folder = folder - self.setRootIndex(index) - return index - - def get_current_folder(self): - return self.__last_folder - - def refresh(self, new_path=None, force_current=False): - """ - Refresh widget - - Parameters - ---------- - new_path: str, optional - New path to refresh the widget. - force_current: bool, optional - If False, it won't refresh widget if path has not changed. - """ - if new_path is None: - new_path = getcwd_or_home() - if force_current: - index = self.set_current_folder(new_path) - self.expand(index) - self.setCurrentIndex(index) - - self.previous_action.setEnabled(False) - self.next_action.setEnabled(False) - - if self.histindex is not None: - self.previous_action.setEnabled(self.histindex > 0) - self.next_action.setEnabled(self.histindex < len(self.history) - 1) - - # ---- Events - def directory_clicked(self, dirname, index): - if dirname: - self.chdir(directory=dirname) - - # ---- Files/Directories Actions - @Slot() - def go_to_parent_directory(self): - """Go to parent directory""" - self.chdir(osp.abspath(osp.join(getcwd_or_home(), os.pardir))) - - @Slot() - def go_to_previous_directory(self): - """Back to previous directory""" - self.histindex -= 1 - self.chdir(browsing_history=True) - - @Slot() - def go_to_next_directory(self): - """Return to next directory""" - self.histindex += 1 - self.chdir(browsing_history=True) - - def update_history(self, directory): - """ - Update browse history. - - Parameters - ---------- - directory: str - The new working directory. - """ - try: - directory = osp.abspath(str(directory)) - if directory in self.history: - self.histindex = self.history.index(directory) - except Exception: - user_directory = get_home_dir() - self.chdir(directory=user_directory, browsing_history=True) - - def chdir(self, directory=None, browsing_history=False, emit=True): - """ - Set directory as working directory. - - Parameters - ---------- - directory: str - The new working directory. - browsing_history: bool, optional - Add the new `directory`to the browsing history. Default is False. - emit: bool, optional - Emit a signal when changing the working directpory. - Default is True. - """ - if directory is not None: - directory = osp.abspath(str(directory)) - if browsing_history: - directory = self.history[self.histindex] - elif directory in self.history: - self.histindex = self.history.index(directory) - else: - if self.histindex is None: - self.history = [] - else: - self.history = self.history[:self.histindex+1] - if len(self.history) == 0 or \ - (self.history and self.history[-1] != directory): - self.history.append(directory) - self.histindex = len(self.history)-1 - directory = str(directory) - - try: - os.chdir(directory) - self.refresh(new_path=directory, force_current=True) - if emit: - self.sig_dir_opened.emit(directory) - except PermissionError: - QMessageBox.critical(self._parent, "Error", - _("You don't have the right permissions to " - "open this directory")) - except FileNotFoundError: - # Handle renaming directories on the fly. - # See spyder-ide/spyder#5183 - self.history.pop(self.histindex) +# -*- coding: utf-8 -*- +# +# Copyright © Spyder Project Contributors +# Licensed under the terms of the MIT License +# (see spyder/__init__.py for details) + +"""Files and Directories Explorer""" + +# pylint: disable=C0103 +# pylint: disable=R0903 +# pylint: disable=R0911 +# pylint: disable=R0201 + +from __future__ import with_statement + +# Standard library imports +import os +import os.path as osp +import re +import shutil +import sys + +# Third party imports +from qtpy import PYQT5 +from qtpy.compat import getexistingdirectory, getsavefilename +from qtpy.QtCore import QDir, QMimeData, Qt, QTimer, QUrl, Signal, Slot +from qtpy.QtGui import QDrag +from qtpy.QtWidgets import (QApplication, QDialog, QDialogButtonBox, + QFileSystemModel, QInputDialog, QLabel, QLineEdit, + QMessageBox, QProxyStyle, QStyle, QTextEdit, + QToolTip, QTreeView, QVBoxLayout) + +# Local imports +from spyder.api.config.decorators import on_conf_change +from spyder.api.translations import get_translation +from spyder.api.widgets.mixins import SpyderWidgetMixin +from spyder.config.base import get_home_dir +from spyder.config.main import NAME_FILTERS +from spyder.plugins.explorer.widgets.utils import ( + create_script, fixpath, IconProvider, show_in_external_file_explorer) +from spyder.py3compat import to_binary_string +from spyder.utils import encoding +from spyder.utils.icon_manager import ima +from spyder.utils import misc, programs, vcs +from spyder.utils.misc import getcwd_or_home +from spyder.utils.qthelpers import file_uri, start_file + +try: + from nbconvert import PythonExporter as nbexporter +except: + nbexporter = None # analysis:ignore + + +# Localization +_ = get_translation('spyder') + + +# ---- Constants +# ---------------------------------------------------------------------------- +class DirViewColumns: + Size = 1 + Type = 2 + Date = 3 + + +class DirViewOpenWithSubMenuSections: + Main = 'Main' + + +class DirViewActions: + # Toggles + ToggleDateColumn = 'toggle_date_column_action' + ToggleSingleClick = 'toggle_single_click_to_open_action' + ToggleSizeColumn = 'toggle_size_column_action' + ToggleTypeColumn = 'toggle_type_column_action' + ToggleHiddenFiles = 'toggle_show_hidden_action' + + # Triggers + EditNameFilters = 'edit_name_filters_action' + NewFile = 'new_file_action' + NewModule = 'new_module_action' + NewFolder = 'new_folder_action' + NewPackage = 'new_package_action' + OpenWithSpyder = 'open_with_spyder_action' + OpenWithSystem = 'open_with_system_action' + OpenWithSystem2 = 'open_with_system_2_action' + Delete = 'delete_action' + Rename = 'rename_action' + Move = 'move_action' + Copy = 'copy_action' + Paste = 'paste_action' + CopyAbsolutePath = 'copy_absolute_path_action' + CopyRelativePath = 'copy_relative_path_action' + ShowInSystemExplorer = 'show_system_explorer_action' + VersionControlCommit = 'version_control_commit_action' + VersionControlBrowse = 'version_control_browse_action' + ConvertNotebook = 'convert_notebook_action' + + # TODO: Move this to the IPython Console + OpenInterpreter = 'open_interpreter_action' + Run = 'run_action' + + +class DirViewMenus: + Context = 'context_menu' + Header = 'header_menu' + New = 'new_menu' + OpenWith = 'open_with_menu' + + +class DirViewHeaderMenuSections: + Main = 'main_section' + + +class DirViewNewSubMenuSections: + General = 'general_section' + Language = 'language_section' + + +class DirViewContextMenuSections: + CopyPaste = 'copy_paste_section' + Extras = 'extras_section' + New = 'new_section' + System = 'system_section' + VersionControl = 'version_control_section' + + +class ExplorerTreeWidgetActions: + # Toggles + ToggleFilter = 'toggle_filter_files_action' + + # Triggers + Next = 'next_action' + Parent = 'parent_action' + Previous = 'previous_action' + + +# ---- Styles +# ---------------------------------------------------------------------------- +class DirViewStyle(QProxyStyle): + + def styleHint(self, hint, option=None, widget=None, return_data=None): + """ + To show tooltips with longer delays. + + From https://stackoverflow.com/a/59059919/438386 + """ + if hint == QStyle.SH_ToolTip_WakeUpDelay: + return 1000 # 1 sec + elif hint == QStyle.SH_ToolTip_FallAsleepDelay: + # This removes some flickering when showing tooltips + return 0 + + return super().styleHint(hint, option, widget, return_data) + + +# ---- Widgets +# ---------------------------------------------------------------------------- +class DirView(QTreeView, SpyderWidgetMixin): + """Base file/directory tree view.""" + + # Signals + sig_file_created = Signal(str) + """ + This signal is emitted when a file is created + + Parameters + ---------- + module: str + Path to the created file. + """ + + sig_open_interpreter_requested = Signal(str) + """ + This signal is emitted when the interpreter opened is requested + + Parameters + ---------- + module: str + Path to use as working directory of interpreter. + """ + + sig_module_created = Signal(str) + """ + This signal is emitted when a new python module is created. + + Parameters + ---------- + module: str + Path to the new module created. + """ + + sig_redirect_stdio_requested = Signal(bool) + """ + This signal is emitted when redirect stdio is requested. + + Parameters + ---------- + enable: bool + Enable/Disable standard input/output redirection. + """ + + sig_removed = Signal(str) + """ + This signal is emitted when a file is removed. + + Parameters + ---------- + path: str + File path removed. + """ + + sig_renamed = Signal(str, str) + """ + This signal is emitted when a file is renamed. + + Parameters + ---------- + old_path: str + Old path for renamed file. + new_path: str + New path for renamed file. + """ + + sig_run_requested = Signal(str) + """ + This signal is emitted to request running a file. + + Parameters + ---------- + path: str + File path to run. + """ + + sig_tree_removed = Signal(str) + """ + This signal is emitted when a folder is removed. + + Parameters + ---------- + path: str + Folder to remove. + """ + + sig_tree_renamed = Signal(str, str) + """ + This signal is emitted when a folder is renamed. + + Parameters + ---------- + old_path: str + Old path for renamed folder. + new_path: str + New path for renamed folder. + """ + + sig_open_file_requested = Signal(str) + """ + This signal is emitted to request opening a new file with Spyder. + + Parameters + ---------- + path: str + File path to run. + """ + + def __init__(self, parent=None): + """Initialize the DirView. + + Parameters + ---------- + parent: QWidget + Parent QWidget of the widget. + """ + if PYQT5: + super().__init__(parent=parent, class_parent=parent) + else: + QTreeView.__init__(self, parent) + SpyderWidgetMixin.__init__(self, class_parent=parent) + + # Attributes + self._parent = parent + self._last_column = 0 + self._last_order = True + self._scrollbar_positions = None + self._to_be_loaded = None + self.__expanded_state = None + self.common_actions = None + self.filter_on = False + self.expanded_or_colapsed_by_mouse = False + + # Widgets + self.fsmodel = None + self.menu = None + self.header_menu = None + header = self.header() + + # Signals + header.customContextMenuRequested.connect(self.show_header_menu) + + # Style adjustments + self._style = DirViewStyle(None) + self._style.setParent(self) + self.setStyle(self._style) + + # Setup + self.setup_fs_model() + self.setSelectionMode(self.ExtendedSelection) + header.setContextMenuPolicy(Qt.CustomContextMenu) + + # Track mouse movements. This activates the mouseMoveEvent declared + # below. + self.setMouseTracking(True) + + # ---- SpyderWidgetMixin API + # ------------------------------------------------------------------------ + def setup(self): + self.setup_view() + + # New actions + new_file_action = self.create_action( + DirViewActions.NewFile, + text=_("File..."), + icon=self.create_icon('TextFileIcon'), + triggered=lambda: self.new_file(), + ) + + new_module_action = self.create_action( + DirViewActions.NewModule, + text=_("Python file..."), + icon=self.create_icon('python'), + triggered=lambda: self.new_module(), + ) + + new_folder_action = self.create_action( + DirViewActions.NewFolder, + text=_("Folder..."), + icon=self.create_icon('folder_new'), + triggered=lambda: self.new_folder(), + ) + + new_package_action = self.create_action( + DirViewActions.NewPackage, + text=_("Python Package..."), + icon=self.create_icon('package_new'), + triggered=lambda: self.new_package(), + ) + + # Open actions + self.open_with_spyder_action = self.create_action( + DirViewActions.OpenWithSpyder, + text=_("Open in Spyder"), + icon=self.create_icon('edit'), + triggered=lambda: self.open(), + ) + + self.open_external_action = self.create_action( + DirViewActions.OpenWithSystem, + text=_("Open externally"), + triggered=lambda: self.open_external(), + ) + + self.open_external_action_2 = self.create_action( + DirViewActions.OpenWithSystem2, + text=_("Default external application"), + triggered=lambda: self.open_external(), + register_shortcut=False, + ) + + # File management actions + delete_action = self.create_action( + DirViewActions.Delete, + text=_("Delete..."), + icon=self.create_icon('editdelete'), + triggered=lambda: self.delete(), + ) + + rename_action = self.create_action( + DirViewActions.Rename, + text=_("Rename..."), + icon=self.create_icon('rename'), + triggered=lambda: self.rename(), + ) + + self.move_action = self.create_action( + DirViewActions.Move, + text=_("Move..."), + icon=self.create_icon('move'), + triggered=lambda: self.move(), + ) + + # Copy/Paste actions + copy_action = self.create_action( + DirViewActions.Copy, + text=_("Copy"), + icon=self.create_icon('editcopy'), + triggered=lambda: self.copy_file_clipboard(), + ) + + self.paste_action = self.create_action( + DirViewActions.Paste, + text=_("Paste"), + icon=self.create_icon('editpaste'), + triggered=lambda: self.save_file_clipboard(), + ) + + copy_absolute_path_action = self.create_action( + DirViewActions.CopyAbsolutePath, + text=_("Copy Absolute Path"), + triggered=lambda: self.copy_absolute_path(), + ) + + copy_relative_path_action = self.create_action( + DirViewActions.CopyRelativePath, + text=_("Copy Relative Path"), + triggered=lambda: self.copy_relative_path(), + ) + + # Show actions + if sys.platform == 'darwin': + show_in_finder_text = _("Show in Finder") + else: + show_in_finder_text = _("Show in Folder") + + show_in_system_explorer_action = self.create_action( + DirViewActions.ShowInSystemExplorer, + text=show_in_finder_text, + triggered=lambda: self.show_in_external_file_explorer(), + ) + + # Version control actions + self.vcs_commit_action = self.create_action( + DirViewActions.VersionControlCommit, + text=_("Commit"), + icon=self.create_icon('vcs_commit'), + triggered=lambda: self.vcs_command('commit'), + ) + self.vcs_log_action = self.create_action( + DirViewActions.VersionControlBrowse, + text=_("Browse repository"), + icon=self.create_icon('vcs_browse'), + triggered=lambda: self.vcs_command('browse'), + ) + + # Common actions + self.hidden_action = self.create_action( + DirViewActions.ToggleHiddenFiles, + text=_("Show hidden files"), + toggled=True, + initial=self.get_conf('show_hidden'), + option='show_hidden' + ) + + self.filters_action = self.create_action( + DirViewActions.EditNameFilters, + text=_("Edit filter settings..."), + icon=self.create_icon('filter'), + triggered=lambda: self.edit_filter(), + ) + + self.create_action( + DirViewActions.ToggleSingleClick, + text=_("Single click to open"), + toggled=True, + initial=self.get_conf('single_click_to_open'), + option='single_click_to_open' + ) + + # IPython console actions + # TODO: Move this option to the ipython console setup + self.open_interpreter_action = self.create_action( + DirViewActions.OpenInterpreter, + text=_("Open IPython console here"), + triggered=lambda: self.open_interpreter(), + ) + + # TODO: Move this option to the ipython console setup + run_action = self.create_action( + DirViewActions.Run, + text=_("Run"), + icon=self.create_icon('run'), + triggered=lambda: self.run(), + ) + + # Notebook Actions + ipynb_convert_action = self.create_action( + DirViewActions.ConvertNotebook, + _("Convert to Python file"), + icon=ima.icon('python'), + triggered=lambda: self.convert_notebooks() + ) + + # Header Actions + size_column_action = self.create_action( + DirViewActions.ToggleSizeColumn, + text=_('Size'), + toggled=True, + initial=self.get_conf('size_column'), + register_shortcut=False, + option='size_column' + ) + type_column_action = self.create_action( + DirViewActions.ToggleTypeColumn, + text=_('Type') if sys.platform == 'darwin' else _('Type'), + toggled=True, + initial=self.get_conf('type_column'), + register_shortcut=False, + option='type_column' + ) + date_column_action = self.create_action( + DirViewActions.ToggleDateColumn, + text=_("Date modified"), + toggled=True, + initial=self.get_conf('date_column'), + register_shortcut=False, + option='date_column' + ) + + # Header Context Menu + self.header_menu = self.create_menu(DirViewMenus.Header) + for item in [size_column_action, type_column_action, + date_column_action]: + self.add_item_to_menu( + item, + menu=self.header_menu, + section=DirViewHeaderMenuSections.Main, + ) + + # New submenu + new_submenu = self.create_menu( + DirViewMenus.New, + _('New'), + ) + for item in [new_file_action, new_folder_action]: + self.add_item_to_menu( + item, + menu=new_submenu, + section=DirViewNewSubMenuSections.General, + ) + + for item in [new_module_action, new_package_action]: + self.add_item_to_menu( + item, + menu=new_submenu, + section=DirViewNewSubMenuSections.Language, + ) + + # Open with submenu + self.open_with_submenu = self.create_menu( + DirViewMenus.OpenWith, + _('Open with'), + ) + + # Context submenu + self.context_menu = self.create_menu(DirViewMenus.Context) + for item in [new_submenu, run_action, + self.open_with_spyder_action, + self.open_with_submenu, + self.open_external_action, + delete_action, rename_action, self.move_action]: + self.add_item_to_menu( + item, + menu=self.context_menu, + section=DirViewContextMenuSections.New, + ) + + # Copy/Paste section + for item in [copy_action, self.paste_action, copy_absolute_path_action, + copy_relative_path_action]: + self.add_item_to_menu( + item, + menu=self.context_menu, + section=DirViewContextMenuSections.CopyPaste, + ) + + self.add_item_to_menu( + show_in_system_explorer_action, + menu=self.context_menu, + section=DirViewContextMenuSections.System, + ) + + # Version control section + for item in [self.vcs_commit_action, self.vcs_log_action]: + self.add_item_to_menu( + item, + menu=self.context_menu, + section=DirViewContextMenuSections.VersionControl + ) + + for item in [self.open_interpreter_action, ipynb_convert_action]: + self.add_item_to_menu( + item, + menu=self.context_menu, + section=DirViewContextMenuSections.Extras, + ) + + # Signals + self.context_menu.aboutToShow.connect(self.update_actions) + + @on_conf_change(option=['size_column', 'type_column', 'date_column', + 'name_filters', 'show_hidden', + 'single_click_to_open']) + def on_conf_update(self, option, value): + if option == 'size_column': + self.setColumnHidden(DirViewColumns.Size, not value) + elif option == 'type_column': + self.setColumnHidden(DirViewColumns.Type, not value) + elif option == 'date_column': + self.setColumnHidden(DirViewColumns.Date, not value) + elif option == 'name_filters': + if self.filter_on: + self.filter_files(value) + elif option == 'show_hidden': + self.set_show_hidden(value) + elif option == 'single_click_to_open': + self.set_single_click_to_open(value) + + def update_actions(self): + fnames = self.get_selected_filenames() + if fnames: + if osp.isdir(fnames[0]): + dirname = fnames[0] + else: + dirname = osp.dirname(fnames[0]) + + basedir = fixpath(osp.dirname(fnames[0])) + only_dirs = fnames and all([osp.isdir(fname) for fname in fnames]) + only_files = all([osp.isfile(fname) for fname in fnames]) + only_valid = all([encoding.is_text_file(fna) for fna in fnames]) + else: + only_files = False + only_valid = False + only_dirs = False + dirname = '' + basedir = '' + + vcs_visible = vcs.is_vcs_repository(dirname) + + # Make actions visible conditionally + self.move_action.setVisible( + all([fixpath(osp.dirname(fname)) == basedir for fname in fnames])) + self.open_external_action.setVisible(False) + self.open_interpreter_action.setVisible(only_dirs) + self.open_with_spyder_action.setVisible(only_files and only_valid) + self.open_with_submenu.menuAction().setVisible(False) + clipboard = QApplication.clipboard() + has_urls = clipboard.mimeData().hasUrls() + self.paste_action.setDisabled(not has_urls) + + # VCS support is quite limited for now, so we are enabling the VCS + # related actions only when a single file/folder is selected: + self.vcs_commit_action.setVisible(vcs_visible) + self.vcs_log_action.setVisible(vcs_visible) + + if only_files: + if len(fnames) == 1: + assoc = self.get_file_associations(fnames[0]) + elif len(fnames) > 1: + assoc = self.get_common_file_associations(fnames) + + if len(assoc) >= 1: + actions = self._create_file_associations_actions() + self.open_with_submenu.menuAction().setVisible(True) + self.open_with_submenu.clear_actions() + for action in actions: + self.add_item_to_menu( + action, + menu=self.open_with_submenu, + section=DirViewOpenWithSubMenuSections.Main, + ) + else: + self.open_external_action.setVisible(True) + + fnames = self.get_selected_filenames() + only_notebooks = all([osp.splitext(fname)[1] == '.ipynb' + for fname in fnames]) + only_modules = all([osp.splitext(fname)[1] in ('.py', '.pyw', '.ipy') + for fname in fnames]) + + nb_visible = only_notebooks and nbexporter is not None + self.get_action(DirViewActions.ConvertNotebook).setVisible(nb_visible) + self.get_action(DirViewActions.Run).setVisible(only_modules) + + def _create_file_associations_actions(self, fnames=None): + """ + Create file association actions. + """ + if fnames is None: + fnames = self.get_selected_filenames() + + actions = [] + only_files = all([osp.isfile(fname) for fname in fnames]) + if only_files: + if len(fnames) == 1: + assoc = self.get_file_associations(fnames[0]) + elif len(fnames) > 1: + assoc = self.get_common_file_associations(fnames) + + if len(assoc) >= 1: + for app_name, fpath in assoc: + text = app_name + if not (os.path.isfile(fpath) or os.path.isdir(fpath)): + text += _(' (Application not found!)') + + try: + # Action might have been created already + open_assoc = self.open_association + open_with_action = self.create_action( + app_name, + text=text, + triggered=lambda x, y=fpath: open_assoc(y), + register_shortcut=False, + ) + except Exception: + open_with_action = self.get_action(app_name) + + # Disconnect previous signal in case the app path + # changed + try: + open_with_action.triggered.disconnect() + except Exception: + pass + + # Reconnect the trigger signal + open_with_action.triggered.connect( + lambda x, y=fpath: self.open_association(y) + ) + + if not (os.path.isfile(fpath) or os.path.isdir(fpath)): + open_with_action.setDisabled(True) + + actions.append(open_with_action) + + actions.append(self.open_external_action_2) + + return actions + + # ---- Qt overrides + # ------------------------------------------------------------------------ + def sortByColumn(self, column, order=Qt.AscendingOrder): + """Override Qt method.""" + header = self.header() + header.setSortIndicatorShown(True) + QTreeView.sortByColumn(self, column, order) + header.setSortIndicator(0, order) + self._last_column = column + self._last_order = not self._last_order + + def viewportEvent(self, event): + """Reimplement Qt method""" + + # Prevent Qt from crashing or showing warnings like: + # "QSortFilterProxyModel: index from wrong model passed to + # mapFromSource", probably due to the fact that the file system model + # is being built. See spyder-ide/spyder#1250. + # + # This workaround was inspired by the following KDE bug: + # https://bugs.kde.org/show_bug.cgi?id=172198 + # + # Apparently, this is a bug from Qt itself. + self.executeDelayedItemsLayout() + + return QTreeView.viewportEvent(self, event) + + def contextMenuEvent(self, event): + """Override Qt method""" + # Needed to handle not initialized menu. + # See spyder-ide/spyder#6975 + try: + fnames = self.get_selected_filenames() + if len(fnames) != 0: + self.context_menu.popup(event.globalPos()) + except AttributeError: + pass + + def keyPressEvent(self, event): + """Reimplement Qt method""" + if event.key() in (Qt.Key_Enter, Qt.Key_Return): + self.clicked() + elif event.key() == Qt.Key_F2: + self.rename() + elif event.key() == Qt.Key_Delete: + self.delete() + elif event.key() == Qt.Key_Backspace: + self.go_to_parent_directory() + else: + QTreeView.keyPressEvent(self, event) + + def mouseDoubleClickEvent(self, event): + """Handle double clicks.""" + super().mouseDoubleClickEvent(event) + if not self.get_conf('single_click_to_open'): + self.clicked(index=self.indexAt(event.pos())) + + def mousePressEvent(self, event): + """ + Detect when a directory was expanded or collapsed by clicking + on its arrow. + + Taken from https://stackoverflow.com/a/13142586/438386 + """ + clicked_index = self.indexAt(event.pos()) + if clicked_index.isValid(): + vrect = self.visualRect(clicked_index) + item_identation = vrect.x() - self.visualRect(self.rootIndex()).x() + if event.pos().x() < item_identation: + self.expanded_or_colapsed_by_mouse = True + else: + self.expanded_or_colapsed_by_mouse = False + super().mousePressEvent(event) + + def mouseReleaseEvent(self, event): + """Handle single clicks.""" + super().mouseReleaseEvent(event) + if self.get_conf('single_click_to_open'): + self.clicked(index=self.indexAt(event.pos())) + + def mouseMoveEvent(self, event): + """Actions to take with mouse movements.""" + # To hide previous tooltip + QToolTip.hideText() + + index = self.indexAt(event.pos()) + if index.isValid(): + if self.get_conf('single_click_to_open'): + vrect = self.visualRect(index) + item_identation = ( + vrect.x() - self.visualRect(self.rootIndex()).x() + ) + + if event.pos().x() > item_identation: + # When hovering over directories or files + self.setCursor(Qt.PointingHandCursor) + else: + # On every other element + self.setCursor(Qt.ArrowCursor) + + self.setToolTip(self.get_filename(index)) + + super().mouseMoveEvent(event) + + def dragEnterEvent(self, event): + """Drag and Drop - Enter event""" + event.setAccepted(event.mimeData().hasFormat("text/plain")) + + def dragMoveEvent(self, event): + """Drag and Drop - Move event""" + if (event.mimeData().hasFormat("text/plain")): + event.setDropAction(Qt.MoveAction) + event.accept() + else: + event.ignore() + + def startDrag(self, dropActions): + """Reimplement Qt Method - handle drag event""" + data = QMimeData() + data.setUrls([QUrl(fname) for fname in self.get_selected_filenames()]) + drag = QDrag(self) + drag.setMimeData(data) + drag.exec_() + + # ---- Model + # ------------------------------------------------------------------------ + def setup_fs_model(self): + """Setup filesystem model""" + self.fsmodel = QFileSystemModel(self) + self.fsmodel.setNameFilterDisables(False) + + def install_model(self): + """Install filesystem model""" + self.setModel(self.fsmodel) + + def setup_view(self): + """Setup view""" + self.install_model() + self.fsmodel.directoryLoaded.connect( + lambda: self.resizeColumnToContents(0)) + self.setAnimated(False) + self.setSortingEnabled(True) + self.sortByColumn(0, Qt.AscendingOrder) + self.fsmodel.modelReset.connect(self.reset_icon_provider) + self.reset_icon_provider() + + # ---- File/Dir Helpers + # ------------------------------------------------------------------------ + def get_filename(self, index): + """Return filename associated with *index*""" + if index: + return osp.normpath(str(self.fsmodel.filePath(index))) + + def get_index(self, filename): + """Return index associated with filename""" + return self.fsmodel.index(filename) + + def get_selected_filenames(self): + """Return selected filenames""" + fnames = [] + if self.selectionMode() == self.ExtendedSelection: + if self.selectionModel() is not None: + fnames = [self.get_filename(idx) for idx in + self.selectionModel().selectedRows()] + else: + fnames = [self.get_filename(self.currentIndex())] + + return fnames + + def get_dirname(self, index): + """Return dirname associated with *index*""" + fname = self.get_filename(index) + if fname: + if osp.isdir(fname): + return fname + else: + return osp.dirname(fname) + + # ---- General actions API + # ------------------------------------------------------------------------ + def show_header_menu(self, pos): + """Display header menu.""" + self.header_menu.popup(self.mapToGlobal(pos)) + + def clicked(self, index=None): + """ + Selected item was single/double-clicked or enter/return was pressed. + """ + fnames = self.get_selected_filenames() + + # Don't do anything when clicking on the arrow next to a directory + # to expand/collapse it. If clicking on its name, use it as `fnames`. + if index and index.isValid(): + fname = self.get_filename(index) + if osp.isdir(fname): + if self.expanded_or_colapsed_by_mouse: + return + else: + fnames = [fname] + + # Open files or directories + for fname in fnames: + if osp.isdir(fname): + self.directory_clicked(fname, index) + else: + if len(fnames) == 1: + assoc = self.get_file_associations(fnames[0]) + elif len(fnames) > 1: + assoc = self.get_common_file_associations(fnames) + + if assoc: + self.open_association(assoc[0][-1]) + else: + self.open([fname]) + + def directory_clicked(self, dirname, index): + """ + Handle directories being clicked. + + Parameters + ---------- + dirname: str + Path to the clicked directory. + index: QModelIndex + Index of the directory. + """ + raise NotImplementedError('To be implemented by subclasses') + + @Slot() + def edit_filter(self): + """Edit name filters.""" + # Create Dialog + dialog = QDialog(self) + dialog.resize(500, 300) + dialog.setWindowTitle(_('Edit filter settings')) + + # Create dialog contents + description_label = QLabel( + _('Filter files by name, extension, or more using ' + 'glob' + ' patterns. Please enter the glob patterns of the files you ' + 'want to show, separated by commas.')) + description_label.setOpenExternalLinks(True) + description_label.setWordWrap(True) + filters = QTextEdit(", ".join(self.get_conf('name_filters')), + parent=self) + layout = QVBoxLayout() + layout.addWidget(description_label) + layout.addWidget(filters) + + def handle_ok(): + filter_text = filters.toPlainText() + filter_text = [f.strip() for f in str(filter_text).split(',')] + self.set_name_filters(filter_text) + dialog.accept() + + def handle_reset(): + self.set_name_filters(NAME_FILTERS) + filters.setPlainText(", ".join(self.get_conf('name_filters'))) + + # Dialog buttons + button_box = QDialogButtonBox(QDialogButtonBox.Reset | + QDialogButtonBox.Ok | + QDialogButtonBox.Cancel) + button_box.accepted.connect(handle_ok) + button_box.rejected.connect(dialog.reject) + button_box.button(QDialogButtonBox.Reset).clicked.connect(handle_reset) + layout.addWidget(button_box) + dialog.setLayout(layout) + dialog.show() + + @Slot() + def open(self, fnames=None): + """Open files with the appropriate application""" + if fnames is None: + fnames = self.get_selected_filenames() + for fname in fnames: + if osp.isfile(fname) and encoding.is_text_file(fname): + self.sig_open_file_requested.emit(fname) + else: + self.open_outside_spyder([fname]) + + @Slot() + def open_association(self, app_path): + """Open files with given application executable path.""" + if not (os.path.isdir(app_path) or os.path.isfile(app_path)): + return_codes = {app_path: 1} + app_path = None + else: + return_codes = {} + + if app_path: + fnames = self.get_selected_filenames() + return_codes = programs.open_files_with_application(app_path, + fnames) + self.check_launch_error_codes(return_codes) + + @Slot() + def open_external(self, fnames=None): + """Open files with default application""" + if fnames is None: + fnames = self.get_selected_filenames() + for fname in fnames: + self.open_outside_spyder([fname]) + + def open_outside_spyder(self, fnames): + """ + Open file outside Spyder with the appropriate application. + + If this does not work, opening unknown file in Spyder, as text file. + """ + for path in sorted(fnames): + path = file_uri(path) + ok = start_file(path) + if not ok and encoding.is_text_file(path): + self.sig_open_file_requested.emit(path) + + def remove_tree(self, dirname): + """ + Remove whole directory tree + + Reimplemented in project explorer widget + """ + while osp.exists(dirname): + try: + shutil.rmtree(dirname, onerror=misc.onerror) + except Exception as e: + # This handles a Windows problem with shutil.rmtree. + # See spyder-ide/spyder#8567. + if type(e).__name__ == "OSError": + error_path = str(e.filename) + shutil.rmtree(error_path, ignore_errors=True) + + def delete_file(self, fname, multiple, yes_to_all): + """Delete file""" + if multiple: + buttons = (QMessageBox.Yes | QMessageBox.YesToAll | + QMessageBox.No | QMessageBox.Cancel) + else: + buttons = QMessageBox.Yes | QMessageBox.No + if yes_to_all is None: + answer = QMessageBox.warning( + self, _("Delete"), + _("Do you really want to delete %s?" + ) % osp.basename(fname), buttons) + if answer == QMessageBox.No: + return yes_to_all + elif answer == QMessageBox.Cancel: + return False + elif answer == QMessageBox.YesToAll: + yes_to_all = True + try: + if osp.isfile(fname): + misc.remove_file(fname) + self.sig_removed.emit(fname) + else: + self.remove_tree(fname) + self.sig_tree_removed.emit(fname) + return yes_to_all + except EnvironmentError as error: + action_str = _('delete') + QMessageBox.critical( + self, _("Project Explorer"), + _("Unable to %s %s

Error message:
%s" + ) % (action_str, fname, str(error))) + return False + + @Slot() + def delete(self, fnames=None): + """Delete files""" + if fnames is None: + fnames = self.get_selected_filenames() + multiple = len(fnames) > 1 + yes_to_all = None + for fname in fnames: + spyproject_path = osp.join(fname, '.spyproject') + if osp.isdir(fname) and osp.exists(spyproject_path): + QMessageBox.information( + self, _('File Explorer'), + _("The current directory contains a project.

" + "If you want to delete the project, please go to " + "Projects » Delete Project")) + else: + yes_to_all = self.delete_file(fname, multiple, yes_to_all) + if yes_to_all is not None and not yes_to_all: + # Canceled + break + + def rename_file(self, fname): + """Rename file""" + path, valid = QInputDialog.getText( + self, _('Rename'), _('New name:'), QLineEdit.Normal, + osp.basename(fname)) + + if valid: + path = osp.join(osp.dirname(fname), str(path)) + if path == fname: + return + if osp.exists(path): + answer = QMessageBox.warning( + self, _("Rename"), + _("Do you really want to rename %s and " + "overwrite the existing file %s?" + ) % (osp.basename(fname), osp.basename(path)), + QMessageBox.Yes | QMessageBox.No) + if answer == QMessageBox.No: + return + try: + misc.rename_file(fname, path) + if osp.isfile(path): + self.sig_renamed.emit(fname, path) + else: + self.sig_tree_renamed.emit(fname, path) + return path + except EnvironmentError as error: + QMessageBox.critical( + self, _("Rename"), + _("Unable to rename file %s" + "

Error message:
%s" + ) % (osp.basename(fname), str(error))) + + @Slot() + def show_in_external_file_explorer(self, fnames=None): + """Show file in external file explorer""" + if fnames is None: + fnames = self.get_selected_filenames() + show_in_external_file_explorer(fnames) + + @Slot() + def rename(self, fnames=None): + """Rename files""" + if fnames is None: + fnames = self.get_selected_filenames() + if not isinstance(fnames, (tuple, list)): + fnames = [fnames] + for fname in fnames: + self.rename_file(fname) + + @Slot() + def move(self, fnames=None, directory=None): + """Move files/directories""" + if fnames is None: + fnames = self.get_selected_filenames() + orig = fixpath(osp.dirname(fnames[0])) + while True: + self.sig_redirect_stdio_requested.emit(False) + if directory is None: + folder = getexistingdirectory( + self, _("Select directory"), orig) + else: + folder = directory + self.sig_redirect_stdio_requested.emit(True) + if folder: + folder = fixpath(folder) + if folder != orig: + break + else: + return + for fname in fnames: + basename = osp.basename(fname) + try: + misc.move_file(fname, osp.join(folder, basename)) + except EnvironmentError as error: + QMessageBox.critical( + self, _("Error"), + _("Unable to move %s" + "

Error message:
%s" + ) % (basename, str(error))) + + def create_new_folder(self, current_path, title, subtitle, is_package): + """Create new folder""" + if current_path is None: + current_path = '' + if osp.isfile(current_path): + current_path = osp.dirname(current_path) + name, valid = QInputDialog.getText(self, title, subtitle, + QLineEdit.Normal, "") + if valid: + dirname = osp.join(current_path, str(name)) + try: + os.mkdir(dirname) + except EnvironmentError as error: + QMessageBox.critical( + self, title, + _("Unable to create folder %s" + "

Error message:
%s" + ) % (dirname, str(error))) + finally: + if is_package: + fname = osp.join(dirname, '__init__.py') + try: + with open(fname, 'wb') as f: + f.write(to_binary_string('#')) + return dirname + except EnvironmentError as error: + QMessageBox.critical( + self, title, + _("Unable to create file %s" + "

Error message:
%s" + ) % (fname, str(error))) + + def get_selected_dir(self): + """ Get selected dir + If file is selected the directory containing file is returned. + If multiple items are selected, first item is chosen. + """ + selected_path = self.get_selected_filenames()[0] + if osp.isfile(selected_path): + selected_path = osp.dirname(selected_path) + return fixpath(selected_path) + + def new_folder(self, basedir=None): + """New folder.""" + + if basedir is None: + basedir = self.get_selected_dir() + + title = _('New folder') + subtitle = _('Folder name:') + self.create_new_folder(basedir, title, subtitle, is_package=False) + + def create_new_file(self, current_path, title, filters, create_func): + """Create new file + Returns True if successful""" + if current_path is None: + current_path = '' + if osp.isfile(current_path): + current_path = osp.dirname(current_path) + self.sig_redirect_stdio_requested.emit(False) + fname, _selfilter = getsavefilename(self, title, current_path, filters) + self.sig_redirect_stdio_requested.emit(True) + if fname: + try: + create_func(fname) + return fname + except EnvironmentError as error: + QMessageBox.critical( + self, _("New file"), + _("Unable to create file %s" + "

Error message:
%s" + ) % (fname, str(error))) + + def new_file(self, basedir=None): + """New file""" + + if basedir is None: + basedir = self.get_selected_dir() + + title = _("New file") + filters = _("All files")+" (*)" + + def create_func(fname): + """File creation callback""" + if osp.splitext(fname)[1] in ('.py', '.pyw', '.ipy'): + create_script(fname) + else: + with open(fname, 'wb') as f: + f.write(to_binary_string('')) + fname = self.create_new_file(basedir, title, filters, create_func) + if fname is not None: + self.open([fname]) + + @Slot() + def run(self, fnames=None): + """Run Python scripts""" + if fnames is None: + fnames = self.get_selected_filenames() + for fname in fnames: + self.sig_run_requested.emit(fname) + + def copy_path(self, fnames=None, method="absolute"): + """Copy absolute or relative path to given file(s)/folders(s).""" + cb = QApplication.clipboard() + explorer_dir = self.fsmodel.rootPath() + if fnames is None: + fnames = self.get_selected_filenames() + if not isinstance(fnames, (tuple, list)): + fnames = [fnames] + fnames = [_fn.replace(os.sep, "/") for _fn in fnames] + if len(fnames) > 1: + if method == "absolute": + clipboard_files = ',\n'.join('"' + _fn + '"' for _fn in fnames) + elif method == "relative": + clipboard_files = ',\n'.join('"' + + osp.relpath(_fn, explorer_dir). + replace(os.sep, "/") + '"' + for _fn in fnames) + else: + if method == "absolute": + clipboard_files = fnames[0] + elif method == "relative": + clipboard_files = (osp.relpath(fnames[0], explorer_dir). + replace(os.sep, "/")) + copied_from = self._parent.__class__.__name__ + if copied_from == 'ProjectExplorerWidget' and method == 'relative': + clipboard_files = [path.strip(',"') for path in + clipboard_files.splitlines()] + clipboard_files = ['/'.join(path.strip('/').split('/')[1:]) for + path in clipboard_files] + if len(clipboard_files) > 1: + clipboard_files = ',\n'.join('"' + _fn + '"' for _fn in + clipboard_files) + else: + clipboard_files = clipboard_files[0] + cb.setText(clipboard_files, mode=cb.Clipboard) + + @Slot() + def copy_absolute_path(self): + """Copy absolute paths of named files/directories to the clipboard.""" + self.copy_path(method="absolute") + + @Slot() + def copy_relative_path(self): + """Copy relative paths of named files/directories to the clipboard.""" + self.copy_path(method="relative") + + @Slot() + def copy_file_clipboard(self, fnames=None): + """Copy file(s)/folders(s) to clipboard.""" + if fnames is None: + fnames = self.get_selected_filenames() + if not isinstance(fnames, (tuple, list)): + fnames = [fnames] + try: + file_content = QMimeData() + file_content.setUrls([QUrl.fromLocalFile(_fn) for _fn in fnames]) + cb = QApplication.clipboard() + cb.setMimeData(file_content, mode=cb.Clipboard) + except Exception as e: + QMessageBox.critical( + self, _('File/Folder copy error'), + _("Cannot copy this type of file(s) or " + "folder(s). The error was:\n\n") + str(e)) + + @Slot() + def save_file_clipboard(self, fnames=None): + """Paste file from clipboard into file/project explorer directory.""" + if fnames is None: + fnames = self.get_selected_filenames() + if not isinstance(fnames, (tuple, list)): + fnames = [fnames] + if len(fnames) >= 1: + try: + selected_item = osp.commonpath(fnames) + except AttributeError: + # py2 does not have commonpath + if len(fnames) > 1: + selected_item = osp.normpath( + osp.dirname(osp.commonprefix(fnames))) + else: + selected_item = fnames[0] + if osp.isfile(selected_item): + parent_path = osp.dirname(selected_item) + else: + parent_path = osp.normpath(selected_item) + cb_data = QApplication.clipboard().mimeData() + if cb_data.hasUrls(): + urls = cb_data.urls() + for url in urls: + source_name = url.toLocalFile() + base_name = osp.basename(source_name) + if osp.isfile(source_name): + try: + while base_name in os.listdir(parent_path): + file_no_ext, file_ext = osp.splitext(base_name) + end_number = re.search(r'\d+$', file_no_ext) + if end_number: + new_number = int(end_number.group()) + 1 + else: + new_number = 1 + left_string = re.sub(r'\d+$', '', file_no_ext) + left_string += str(new_number) + base_name = left_string + file_ext + destination = osp.join(parent_path, base_name) + else: + destination = osp.join(parent_path, base_name) + shutil.copy(source_name, destination) + except Exception as e: + QMessageBox.critical(self, _('Error pasting file'), + _("Unsupported copy operation" + ". The error was:\n\n") + + str(e)) + else: + try: + while base_name in os.listdir(parent_path): + end_number = re.search(r'\d+$', base_name) + if end_number: + new_number = int(end_number.group()) + 1 + else: + new_number = 1 + left_string = re.sub(r'\d+$', '', base_name) + base_name = left_string + str(new_number) + destination = osp.join(parent_path, base_name) + else: + destination = osp.join(parent_path, base_name) + if osp.realpath(destination).startswith( + osp.realpath(source_name) + os.sep): + QMessageBox.critical(self, + _('Recursive copy'), + _("Source is an ancestor" + " of destination" + " folder.")) + continue + shutil.copytree(source_name, destination) + except Exception as e: + QMessageBox.critical(self, + _('Error pasting folder'), + _("Unsupported copy" + " operation. The error was:" + "\n\n") + str(e)) + else: + QMessageBox.critical(self, _("No file in clipboard"), + _("No file in the clipboard. Please copy" + " a file to the clipboard first.")) + else: + if QApplication.clipboard().mimeData().hasUrls(): + QMessageBox.critical(self, _('Blank area'), + _("Cannot paste in the blank area.")) + else: + pass + + def open_interpreter(self, fnames=None): + """Open interpreter""" + if fnames is None: + fnames = self.get_selected_filenames() + for path in sorted(fnames): + self.sig_open_interpreter_requested.emit(path) + + def filter_files(self, name_filters=None): + """Filter files given the defined list of filters.""" + if name_filters is None: + name_filters = self.get_conf('name_filters') + + if self.filter_on: + self.fsmodel.setNameFilters(name_filters) + else: + self.fsmodel.setNameFilters([]) + + # ---- File Associations + # ------------------------------------------------------------------------ + def get_common_file_associations(self, fnames): + """ + Return the list of common matching file associations for all fnames. + """ + all_values = [] + for fname in fnames: + values = self.get_file_associations(fname) + all_values.append(values) + + common = set(all_values[0]) + for index in range(1, len(all_values)): + common = common.intersection(all_values[index]) + return list(sorted(common)) + + def get_file_associations(self, fname): + """Return the list of matching file associations for `fname`.""" + for exts, values in self.get_conf('file_associations', {}).items(): + clean_exts = [ext.strip() for ext in exts.split(',')] + for ext in clean_exts: + if fname.endswith((ext, ext[1:])): + values = values + break + else: + continue # Only excecuted if the inner loop did not break + break # Only excecuted if the inner loop did break + else: + values = [] + + return values + + # ---- File/Directory actions + # ------------------------------------------------------------------------ + def check_launch_error_codes(self, return_codes): + """Check return codes and display message box if errors found.""" + errors = [cmd for cmd, code in return_codes.items() if code != 0] + if errors: + if len(errors) == 1: + msg = _('The following command did not launch successfully:') + else: + msg = _('The following commands did not launch successfully:') + + msg += '

' if len(errors) == 1 else '

    ' + for error in errors: + if len(errors) == 1: + msg += '{}'.format(error) + else: + msg += '
  • {}
  • '.format(error) + msg += '' if len(errors) == 1 else '
' + + QMessageBox.warning(self, 'Application', msg, QMessageBox.Ok) + + return not bool(errors) + + # ---- VCS actions + # ------------------------------------------------------------------------ + def vcs_command(self, action): + """VCS action (commit, browse)""" + fnames = self.get_selected_filenames() + + # Get dirname of selection + if osp.isdir(fnames[0]): + dirname = fnames[0] + else: + dirname = osp.dirname(fnames[0]) + + # Run action + try: + for path in sorted(fnames): + vcs.run_vcs_tool(dirname, action) + except vcs.ActionToolNotFound as error: + msg = _("For %s support, please install one of the
" + "following tools:

%s")\ + % (error.vcsname, ', '.join(error.tools)) + QMessageBox.critical( + self, _("Error"), + _("""Unable to find external program.

%s""" + ) % str(msg)) + + # ---- Settings + # ------------------------------------------------------------------------ + def get_scrollbar_position(self): + """Return scrollbar positions""" + return (self.horizontalScrollBar().value(), + self.verticalScrollBar().value()) + + def set_scrollbar_position(self, position): + """Set scrollbar positions""" + # Scrollbars will be restored after the expanded state + self._scrollbar_positions = position + if self._to_be_loaded is not None and len(self._to_be_loaded) == 0: + self.restore_scrollbar_positions() + + def restore_scrollbar_positions(self): + """Restore scrollbar positions once tree is loaded""" + hor, ver = self._scrollbar_positions + self.horizontalScrollBar().setValue(hor) + self.verticalScrollBar().setValue(ver) + + def get_expanded_state(self): + """Return expanded state""" + self.save_expanded_state() + return self.__expanded_state + + def set_expanded_state(self, state): + """Set expanded state""" + self.__expanded_state = state + self.restore_expanded_state() + + def save_expanded_state(self): + """Save all items expanded state""" + model = self.model() + # If model is not installed, 'model' will be None: this happens when + # using the Project Explorer without having selected a workspace yet + if model is not None: + self.__expanded_state = [] + for idx in model.persistentIndexList(): + if self.isExpanded(idx): + self.__expanded_state.append(self.get_filename(idx)) + + def restore_directory_state(self, fname): + """Restore directory expanded state""" + root = osp.normpath(str(fname)) + if not osp.exists(root): + # Directory has been (re)moved outside Spyder + return + for basename in os.listdir(root): + path = osp.normpath(osp.join(root, basename)) + if osp.isdir(path) and path in self.__expanded_state: + self.__expanded_state.pop(self.__expanded_state.index(path)) + if self._to_be_loaded is None: + self._to_be_loaded = [] + self._to_be_loaded.append(path) + self.setExpanded(self.get_index(path), True) + if not self.__expanded_state: + self.fsmodel.directoryLoaded.disconnect( + self.restore_directory_state) + + def follow_directories_loaded(self, fname): + """Follow directories loaded during startup""" + if self._to_be_loaded is None: + return + path = osp.normpath(str(fname)) + if path in self._to_be_loaded: + self._to_be_loaded.remove(path) + if self._to_be_loaded is not None and len(self._to_be_loaded) == 0: + self.fsmodel.directoryLoaded.disconnect( + self.follow_directories_loaded) + if self._scrollbar_positions is not None: + # The tree view need some time to render branches: + QTimer.singleShot(50, self.restore_scrollbar_positions) + + def restore_expanded_state(self): + """Restore all items expanded state""" + if self.__expanded_state is not None: + # In the old project explorer, the expanded state was a + # dictionary: + if isinstance(self.__expanded_state, list): + self.fsmodel.directoryLoaded.connect( + self.restore_directory_state) + self.fsmodel.directoryLoaded.connect( + self.follow_directories_loaded) + + # ---- Options + # ------------------------------------------------------------------------ + def set_single_click_to_open(self, value): + """Set single click to open items.""" + # Reset cursor shape + if not value: + self.unsetCursor() + + def set_file_associations(self, value): + """Set file associations open items.""" + self.set_conf('file_associations', value) + + def set_name_filters(self, name_filters): + """Set name filters""" + if self.get_conf('name_filters') == ['']: + self.set_conf('name_filters', []) + else: + self.set_conf('name_filters', name_filters) + + def set_show_hidden(self, state): + """Toggle 'show hidden files' state""" + filters = (QDir.AllDirs | QDir.Files | QDir.Drives | + QDir.NoDotAndDotDot) + if state: + filters = (QDir.AllDirs | QDir.Files | QDir.Drives | + QDir.NoDotAndDotDot | QDir.Hidden) + self.fsmodel.setFilter(filters) + + def reset_icon_provider(self): + """Reset file system model icon provider + The purpose of this is to refresh files/directories icons""" + self.fsmodel.setIconProvider(IconProvider(self)) + + def convert_notebook(self, fname): + """Convert an IPython notebook to a Python script in editor""" + try: + script = nbexporter().from_filename(fname)[0] + except Exception as e: + QMessageBox.critical( + self, _('Conversion error'), + _("It was not possible to convert this " + "notebook. The error is:\n\n") + str(e)) + return + self.sig_file_created.emit(script) + + def convert_notebooks(self): + """Convert IPython notebooks to Python scripts in editor""" + fnames = self.get_selected_filenames() + if not isinstance(fnames, (tuple, list)): + fnames = [fnames] + for fname in fnames: + self.convert_notebook(fname) + + def new_package(self, basedir=None): + """New package""" + + if basedir is None: + basedir = self.get_selected_dir() + + title = _('New package') + subtitle = _('Package name:') + self.create_new_folder(basedir, title, subtitle, is_package=True) + + def new_module(self, basedir=None): + """New module""" + + if basedir is None: + basedir = self.get_selected_dir() + + title = _("New module") + filters = _("Python files")+" (*.py *.pyw *.ipy)" + + def create_func(fname): + self.sig_module_created.emit(fname) + + self.create_new_file(basedir, title, filters, create_func) + + def go_to_parent_directory(self): + pass + + +class ExplorerTreeWidget(DirView): + """ + File/directory explorer tree widget. + """ + + sig_dir_opened = Signal(str) + """ + This signal is emitted when the current directory of the explorer tree + has changed. + + Parameters + ---------- + new_root_directory: str + The new root directory path. + + Notes + ----- + This happens when clicking (or double clicking depending on the option) + a folder, turning this folder in the new root parent of the tree. + """ + + def __init__(self, parent=None): + """Initialize the widget. + + Parameters + ---------- + parent: PluginMainWidget, optional + Parent widget of the explorer tree widget. + """ + super().__init__(parent=parent) + + # Attributes + self._parent = parent + self.__last_folder = None + self.__original_root_index = None + self.history = [] + self.histindex = None + + # Enable drag events + self.setDragEnabled(True) + + # ---- SpyderWidgetMixin API + # ------------------------------------------------------------------------ + def setup(self): + """ + Perform the setup of the widget. + """ + super().setup() + + # Actions + self.previous_action = self.create_action( + ExplorerTreeWidgetActions.Previous, + text=_("Previous"), + icon=self.create_icon('previous'), + triggered=self.go_to_previous_directory, + ) + self.next_action = self.create_action( + ExplorerTreeWidgetActions.Next, + text=_("Next"), + icon=self.create_icon('next'), + triggered=self.go_to_next_directory, + ) + self.create_action( + ExplorerTreeWidgetActions.Parent, + text=_("Parent"), + icon=self.create_icon('up'), + triggered=self.go_to_parent_directory + ) + + # Toolbuttons + self.filter_button = self.create_action( + ExplorerTreeWidgetActions.ToggleFilter, + text="", + icon=ima.icon('filter'), + toggled=self.change_filter_state + ) + self.filter_button.setCheckable(True) + + def update_actions(self): + """Update the widget actions.""" + super().update_actions() + + # ---- API + # ------------------------------------------------------------------------ + def change_filter_state(self): + """Handle the change of the filter state.""" + self.filter_on = not self.filter_on + self.filter_button.setChecked(self.filter_on) + self.filter_button.setToolTip(_("Filter filenames")) + self.filter_files() + + # ---- Refreshing widget + def set_current_folder(self, folder): + """ + Set current folder and return associated model index + + Parameters + ---------- + folder: str + New path to the selected folder. + """ + index = self.fsmodel.setRootPath(folder) + self.__last_folder = folder + self.setRootIndex(index) + return index + + def get_current_folder(self): + return self.__last_folder + + def refresh(self, new_path=None, force_current=False): + """ + Refresh widget + + Parameters + ---------- + new_path: str, optional + New path to refresh the widget. + force_current: bool, optional + If False, it won't refresh widget if path has not changed. + """ + if new_path is None: + new_path = getcwd_or_home() + if force_current: + index = self.set_current_folder(new_path) + self.expand(index) + self.setCurrentIndex(index) + + self.previous_action.setEnabled(False) + self.next_action.setEnabled(False) + + if self.histindex is not None: + self.previous_action.setEnabled(self.histindex > 0) + self.next_action.setEnabled(self.histindex < len(self.history) - 1) + + # ---- Events + def directory_clicked(self, dirname, index): + if dirname: + self.chdir(directory=dirname) + + # ---- Files/Directories Actions + @Slot() + def go_to_parent_directory(self): + """Go to parent directory""" + self.chdir(osp.abspath(osp.join(getcwd_or_home(), os.pardir))) + + @Slot() + def go_to_previous_directory(self): + """Back to previous directory""" + self.histindex -= 1 + self.chdir(browsing_history=True) + + @Slot() + def go_to_next_directory(self): + """Return to next directory""" + self.histindex += 1 + self.chdir(browsing_history=True) + + def update_history(self, directory): + """ + Update browse history. + + Parameters + ---------- + directory: str + The new working directory. + """ + try: + directory = osp.abspath(str(directory)) + if directory in self.history: + self.histindex = self.history.index(directory) + except Exception: + user_directory = get_home_dir() + self.chdir(directory=user_directory, browsing_history=True) + + def chdir(self, directory=None, browsing_history=False, emit=True): + """ + Set directory as working directory. + + Parameters + ---------- + directory: str + The new working directory. + browsing_history: bool, optional + Add the new `directory`to the browsing history. Default is False. + emit: bool, optional + Emit a signal when changing the working directpory. + Default is True. + """ + if directory is not None: + directory = osp.abspath(str(directory)) + if browsing_history: + directory = self.history[self.histindex] + elif directory in self.history: + self.histindex = self.history.index(directory) + else: + if self.histindex is None: + self.history = [] + else: + self.history = self.history[:self.histindex+1] + if len(self.history) == 0 or \ + (self.history and self.history[-1] != directory): + self.history.append(directory) + self.histindex = len(self.history)-1 + directory = str(directory) + + try: + os.chdir(directory) + self.refresh(new_path=directory, force_current=True) + if emit: + self.sig_dir_opened.emit(directory) + except PermissionError: + QMessageBox.critical(self._parent, "Error", + _("You don't have the right permissions to " + "open this directory")) + except FileNotFoundError: + # Handle renaming directories on the fly. + # See spyder-ide/spyder#5183 + self.history.pop(self.histindex) diff --git a/spyder/plugins/findinfiles/api.py b/spyder/plugins/findinfiles/api.py index 3de4062ed07..c913b183ab7 100644 --- a/spyder/plugins/findinfiles/api.py +++ b/spyder/plugins/findinfiles/api.py @@ -1,14 +1,14 @@ -# -*- coding: utf-8 -*- -# -# Copyright © Spyder Project Contributors -# Licensed under the terms of the MIT License -# (see spyder/__init__.py for details) - -""" -Find in files widget API. -""" - -# Local imports -from spyder.plugins.findinfiles.plugin import FindInFilesActions # noqa -from spyder.plugins.findinfiles.widgets.main_widget import ( # noqa - FindInFilesWidgetActions) +# -*- coding: utf-8 -*- +# +# Copyright © Spyder Project Contributors +# Licensed under the terms of the MIT License +# (see spyder/__init__.py for details) + +""" +Find in files widget API. +""" + +# Local imports +from spyder.plugins.findinfiles.plugin import FindInFilesActions # noqa +from spyder.plugins.findinfiles.widgets.main_widget import ( # noqa + FindInFilesWidgetActions) diff --git a/spyder/plugins/findinfiles/plugin.py b/spyder/plugins/findinfiles/plugin.py index 95ef03a47db..a362a4d223e 100644 --- a/spyder/plugins/findinfiles/plugin.py +++ b/spyder/plugins/findinfiles/plugin.py @@ -1,213 +1,213 @@ -# -*- coding: utf-8 -*- -# -# Copyright © Spyder Project Contributors -# Licensed under the terms of the MIT License -# (see spyder/__init__.py for details) -""" -Find in Files Plugin. -""" - -# Third party imports -from qtpy.QtCore import Qt -from qtpy.QtWidgets import QApplication - -# Local imports -from spyder.api.plugins import Plugins, SpyderDockablePlugin -from spyder.api.plugin_registration.decorators import ( - on_plugin_available, on_plugin_teardown) -from spyder.api.translations import get_translation -from spyder.plugins.findinfiles.widgets.main_widget import FindInFilesWidget -from spyder.plugins.mainmenu.api import ApplicationMenus -from spyder.utils.misc import getcwd_or_home - -# Localization -_ = get_translation('spyder') - - -# --- Constants -# ---------------------------------------------------------------------------- -class FindInFilesActions: - FindInFiles = 'find in files' - - -# --- Plugin -# ---------------------------------------------------------------------------- -class FindInFiles(SpyderDockablePlugin): - """ - Find in files DockWidget. - """ - NAME = 'find_in_files' - REQUIRES = [] - OPTIONAL = [Plugins.Editor, Plugins.Projects, Plugins.MainMenu] - TABIFY = [Plugins.VariableExplorer] - WIDGET_CLASS = FindInFilesWidget - CONF_SECTION = NAME - CONF_FILE = False - RAISE_AND_FOCUS = True - - # --- SpyderDocakblePlugin API - # ------------------------------------------------------------------------ - @staticmethod - def get_name(): - return _("Find") - - def get_description(self): - return _("Search for strings of text in files.") - - def get_icon(self): - return self.create_icon('findf') - - def on_initialize(self): - self.create_action( - FindInFilesActions.FindInFiles, - text=_("Find in files"), - tip=_("Search text in multiple files"), - triggered=self.find, - register_shortcut=True, - context=Qt.WindowShortcut - ) - self.refresh_search_directory() - - @on_plugin_available(plugin=Plugins.Editor) - def on_editor_available(self): - widget = self.get_widget() - editor = self.get_plugin(Plugins.Editor) - widget.sig_edit_goto_requested.connect( - lambda filename, lineno, search_text, colno, colend: editor.load( - filename, lineno, start_column=colno, end_column=colend)) - editor.sig_file_opened_closed_or_updated.connect( - self.set_current_opened_file) - - @on_plugin_available(plugin=Plugins.Projects) - def on_projects_available(self): - projects = self.get_plugin(Plugins.Projects) - projects.sig_project_loaded.connect(self.set_project_path) - projects.sig_project_closed.connect(self.unset_project_path) - - @on_plugin_available(plugin=Plugins.MainMenu) - def on_main_menu_available(self): - mainmenu = self.get_plugin(Plugins.MainMenu) - findinfiles_action = self.get_action(FindInFilesActions.FindInFiles) - - mainmenu.add_item_to_application_menu( - findinfiles_action, - menu_id=ApplicationMenus.Search, - ) - - @on_plugin_teardown(plugin=Plugins.Editor) - def on_editor_teardown(self): - widget = self.get_widget() - editor = self.get_plugin(Plugins.Editor) - widget.sig_edit_goto_requested.disconnect() - editor.sig_file_opened_closed_or_updated.disconnect( - self.set_current_opened_file) - - @on_plugin_teardown(plugin=Plugins.Projects) - def on_projects_teardon_plugin_teardown(self): - projects = self.get_plugin(Plugins.Projects) - projects.sig_project_loaded.disconnect(self.set_project_path) - projects.sig_project_closed.disconnect(self.unset_project_path) - - @on_plugin_teardown(plugin=Plugins.MainMenu) - def on_main_menu_teardown(self): - mainmenu = self.get_plugin(Plugins.MainMenu) - - mainmenu.remove_item_from_application_menu( - FindInFilesActions.FindInFiles, - menu_id=ApplicationMenus.Search, - ) - - def on_close(self, cancelable=False): - self.get_widget()._update_options() - if self.get_widget().running: - self.get_widget()._stop_and_reset_thread(ignore_results=True) - return True - - # --- Public API - # ------------------------------------------------------------------------ - def refresh_search_directory(self): - """ - Refresh search directory. - """ - self.get_widget().set_directory(getcwd_or_home()) - - def set_current_opened_file(self, path, _language): - """ - Set path of current opened file in editor. - - Parameters - ---------- - path: str - Path of editor file. - """ - self.get_widget().set_file_path(path) - - def set_project_path(self, path): - """ - Set and refresh current project path. - - Parameters - ---------- - path: str - Opened project path. - """ - self.get_widget().set_project_path(path) - - def set_max_results(self, value=None): - """ - Set maximum amount of results to add to the result browser. - - Parameters - ---------- - value: int, optional - Number of results. If None an input dialog will be used. - Default is None. - """ - self.get_widget().set_max_results(value) - - def unset_project_path(self): - """ - Unset current project path. - """ - self.get_widget().disable_project_search() - - def find(self): - """ - Search text in multiple files. - - Notes - ----- - Find in files using the currently selected text of the focused widget. - """ - focus_widget = QApplication.focusWidget() - text = '' - try: - if focus_widget.has_selected_text(): - text = focus_widget.get_selected_text() - except AttributeError: - # This is not a text widget deriving from TextEditBaseWidget - pass - - self.switch_to_plugin() - widget = self.get_widget() - - if text: - widget.set_search_text(text) - - widget.find() - - -def test(): - import sys - - from spyder.config.manager import CONF - from spyder.utils.qthelpers import qapplication - - app = qapplication() - widget = FindInFiles(None, CONF) - widget.show() - sys.exit(app.exec_()) - - -if __name__ == '__main__': - test() +# -*- coding: utf-8 -*- +# +# Copyright © Spyder Project Contributors +# Licensed under the terms of the MIT License +# (see spyder/__init__.py for details) +""" +Find in Files Plugin. +""" + +# Third party imports +from qtpy.QtCore import Qt +from qtpy.QtWidgets import QApplication + +# Local imports +from spyder.api.plugins import Plugins, SpyderDockablePlugin +from spyder.api.plugin_registration.decorators import ( + on_plugin_available, on_plugin_teardown) +from spyder.api.translations import get_translation +from spyder.plugins.findinfiles.widgets.main_widget import FindInFilesWidget +from spyder.plugins.mainmenu.api import ApplicationMenus +from spyder.utils.misc import getcwd_or_home + +# Localization +_ = get_translation('spyder') + + +# --- Constants +# ---------------------------------------------------------------------------- +class FindInFilesActions: + FindInFiles = 'find in files' + + +# --- Plugin +# ---------------------------------------------------------------------------- +class FindInFiles(SpyderDockablePlugin): + """ + Find in files DockWidget. + """ + NAME = 'find_in_files' + REQUIRES = [] + OPTIONAL = [Plugins.Editor, Plugins.Projects, Plugins.MainMenu] + TABIFY = [Plugins.VariableExplorer] + WIDGET_CLASS = FindInFilesWidget + CONF_SECTION = NAME + CONF_FILE = False + RAISE_AND_FOCUS = True + + # --- SpyderDocakblePlugin API + # ------------------------------------------------------------------------ + @staticmethod + def get_name(): + return _("Find") + + def get_description(self): + return _("Search for strings of text in files.") + + def get_icon(self): + return self.create_icon('findf') + + def on_initialize(self): + self.create_action( + FindInFilesActions.FindInFiles, + text=_("Find in files"), + tip=_("Search text in multiple files"), + triggered=self.find, + register_shortcut=True, + context=Qt.WindowShortcut + ) + self.refresh_search_directory() + + @on_plugin_available(plugin=Plugins.Editor) + def on_editor_available(self): + widget = self.get_widget() + editor = self.get_plugin(Plugins.Editor) + widget.sig_edit_goto_requested.connect( + lambda filename, lineno, search_text, colno, colend: editor.load( + filename, lineno, start_column=colno, end_column=colend)) + editor.sig_file_opened_closed_or_updated.connect( + self.set_current_opened_file) + + @on_plugin_available(plugin=Plugins.Projects) + def on_projects_available(self): + projects = self.get_plugin(Plugins.Projects) + projects.sig_project_loaded.connect(self.set_project_path) + projects.sig_project_closed.connect(self.unset_project_path) + + @on_plugin_available(plugin=Plugins.MainMenu) + def on_main_menu_available(self): + mainmenu = self.get_plugin(Plugins.MainMenu) + findinfiles_action = self.get_action(FindInFilesActions.FindInFiles) + + mainmenu.add_item_to_application_menu( + findinfiles_action, + menu_id=ApplicationMenus.Search, + ) + + @on_plugin_teardown(plugin=Plugins.Editor) + def on_editor_teardown(self): + widget = self.get_widget() + editor = self.get_plugin(Plugins.Editor) + widget.sig_edit_goto_requested.disconnect() + editor.sig_file_opened_closed_or_updated.disconnect( + self.set_current_opened_file) + + @on_plugin_teardown(plugin=Plugins.Projects) + def on_projects_teardon_plugin_teardown(self): + projects = self.get_plugin(Plugins.Projects) + projects.sig_project_loaded.disconnect(self.set_project_path) + projects.sig_project_closed.disconnect(self.unset_project_path) + + @on_plugin_teardown(plugin=Plugins.MainMenu) + def on_main_menu_teardown(self): + mainmenu = self.get_plugin(Plugins.MainMenu) + + mainmenu.remove_item_from_application_menu( + FindInFilesActions.FindInFiles, + menu_id=ApplicationMenus.Search, + ) + + def on_close(self, cancelable=False): + self.get_widget()._update_options() + if self.get_widget().running: + self.get_widget()._stop_and_reset_thread(ignore_results=True) + return True + + # --- Public API + # ------------------------------------------------------------------------ + def refresh_search_directory(self): + """ + Refresh search directory. + """ + self.get_widget().set_directory(getcwd_or_home()) + + def set_current_opened_file(self, path, _language): + """ + Set path of current opened file in editor. + + Parameters + ---------- + path: str + Path of editor file. + """ + self.get_widget().set_file_path(path) + + def set_project_path(self, path): + """ + Set and refresh current project path. + + Parameters + ---------- + path: str + Opened project path. + """ + self.get_widget().set_project_path(path) + + def set_max_results(self, value=None): + """ + Set maximum amount of results to add to the result browser. + + Parameters + ---------- + value: int, optional + Number of results. If None an input dialog will be used. + Default is None. + """ + self.get_widget().set_max_results(value) + + def unset_project_path(self): + """ + Unset current project path. + """ + self.get_widget().disable_project_search() + + def find(self): + """ + Search text in multiple files. + + Notes + ----- + Find in files using the currently selected text of the focused widget. + """ + focus_widget = QApplication.focusWidget() + text = '' + try: + if focus_widget.has_selected_text(): + text = focus_widget.get_selected_text() + except AttributeError: + # This is not a text widget deriving from TextEditBaseWidget + pass + + self.switch_to_plugin() + widget = self.get_widget() + + if text: + widget.set_search_text(text) + + widget.find() + + +def test(): + import sys + + from spyder.config.manager import CONF + from spyder.utils.qthelpers import qapplication + + app = qapplication() + widget = FindInFiles(None, CONF) + widget.show() + sys.exit(app.exec_()) + + +if __name__ == '__main__': + test() diff --git a/spyder/plugins/findinfiles/widgets/results_browser.py b/spyder/plugins/findinfiles/widgets/results_browser.py index d53f3fccdc5..c9a4080a90c 100644 --- a/spyder/plugins/findinfiles/widgets/results_browser.py +++ b/spyder/plugins/findinfiles/widgets/results_browser.py @@ -1,337 +1,337 @@ -# -*- coding: utf-8 -*- -# -# Copyright © Spyder Project Contributors -# Licensed under the terms of the MIT License -# (see spyder/__init__.py for details) - -"""Results browser.""" - -# Standard library imports -import os.path as osp - -# Third party imports -from qtpy.QtCore import QPoint, QSize, Qt, Signal, Slot -from qtpy.QtGui import (QAbstractTextDocumentLayout, QColor, QBrush, - QFontMetrics, QPalette, QTextDocument) -from qtpy.QtWidgets import (QApplication, QStyle, QStyledItemDelegate, - QStyleOptionViewItem, QTreeWidgetItem) - -# Local imports -from spyder.api.translations import get_translation -from spyder.config.gui import get_font -from spyder.plugins.findinfiles.widgets.search_thread import ( - ELLIPSIS, MAX_RESULT_LENGTH) -from spyder.utils import icon_manager as ima -from spyder.utils.palette import QStylePalette -from spyder.widgets.onecolumntree import OneColumnTree - -# Localization -_ = get_translation('spyder') - - -# ---- Constants -# ---------------------------------------------------------------------------- -ON = 'on' -OFF = 'off' - - -# ---- Items -# ---------------------------------------------------------------------------- -class LineMatchItem(QTreeWidgetItem): - - def __init__(self, parent, lineno, colno, match, font, text_color): - self.lineno = lineno - self.colno = colno - self.match = match['formatted_text'] - self.plain_match = match['text'] - self.text_color = text_color - self.font = font - super().__init__(parent, [self.__repr__()], QTreeWidgetItem.Type) - - def __repr__(self): - match = str(self.match).rstrip() - _str = ( - f"" - f"

" - f'  ' - f"{self.lineno} ({self.colno}): " - f"{match}

" - ) - return _str - - def __unicode__(self): - return self.__repr__() - - def __str__(self): - return self.__repr__() - - def __lt__(self, x): - return self.lineno < x.lineno - - def __ge__(self, x): - return self.lineno >= x.lineno - - -class FileMatchItem(QTreeWidgetItem): - - def __init__(self, parent, path, filename, sorting, text_color): - - self.sorting = sorting - self.filename = osp.basename(filename) - - # Get relative dirname according to the path we're searching in. - dirname = osp.dirname(filename) - rel_dirname = dirname.split(path)[1] - if rel_dirname.startswith(osp.sep): - rel_dirname = rel_dirname[1:] - self.rel_dirname = rel_dirname - - title = ( - f'' - f'{osp.basename(filename)}' - f'   ' - f'' - f'{self.rel_dirname}' - f'' - ) - - super().__init__(parent, [title], QTreeWidgetItem.Type) - - self.setIcon(0, ima.get_icon_by_extension_or_type(filename, 1.0)) - self.setToolTip(0, filename) - - def __lt__(self, x): - if self.sorting['status'] == ON: - return self.filename < x.filename - else: - return False - - def __ge__(self, x): - if self.sorting['status'] == ON: - return self.filename >= x.filename - else: - return False - - -# ---- Browser -# ---------------------------------------------------------------------------- -class ItemDelegate(QStyledItemDelegate): - - def __init__(self, parent): - super().__init__(parent) - self._margin = None - self._background_color = QColor(QStylePalette.COLOR_BACKGROUND_3) - self.width = 0 - - def paint(self, painter, option, index): - options = QStyleOptionViewItem(option) - self.initStyleOption(options, index) - style = (QApplication.style() if options.widget is None - else options.widget.style()) - - # Set background color for selected and hovered items. - # Inspired by: - # - https://stackoverflow.com/a/43253004/438386 - # - https://stackoverflow.com/a/27274233/438386 - - # This is commented for now until we find a way to correctly colorize - # the entire line with a single color. - # if options.state & QStyle.State_Selected: - # # This only applies when the selected item doesn't have focus - # if not (options.state & QStyle.State_HasFocus): - # options.palette.setBrush( - # QPalette.Highlight, - # QBrush(self._background_color) - # ) - - if options.state & QStyle.State_MouseOver: - painter.fillRect(option.rect, self._background_color) - - # Set text - doc = QTextDocument() - text = options.text - doc.setHtml(text) - doc.setDocumentMargin(0) - - # This needs to be an empty string to avoid overlapping the - # normal text of the QTreeWidgetItem - options.text = "" - style.drawControl(QStyle.CE_ItemViewItem, options, painter) - - ctx = QAbstractTextDocumentLayout.PaintContext() - - textRect = style.subElementRect(QStyle.SE_ItemViewItemText, - options, None) - painter.save() - - painter.translate(textRect.topLeft() + QPoint(0, 4)) - doc.documentLayout().draw(painter, ctx) - painter.restore() - - def sizeHint(self, option, index): - options = QStyleOptionViewItem(option) - self.initStyleOption(options, index) - doc = QTextDocument() - doc.setHtml(options.text) - doc.setTextWidth(options.rect.width()) - size = QSize(self.width, int(doc.size().height())) - return size - - -class ResultsBrowser(OneColumnTree): - sig_edit_goto_requested = Signal(str, int, str, int, int) - sig_max_results_reached = Signal() - - def __init__(self, parent, text_color, max_results=1000): - super().__init__(parent) - self.search_text = None - self.results = None - self.max_results = max_results - self.total_matches = None - self.error_flag = None - self.completed = None - self.sorting = {} - self.font = get_font() - self.data = None - self.files = None - self.root_items = None - self.text_color = text_color - self.path = None - self.longest_file_item = '' - self.longest_line_item = '' - - # Setup - self.set_title('') - self.set_sorting(OFF) - self.setSortingEnabled(False) - self.setItemDelegate(ItemDelegate(self)) - self.setUniformRowHeights(True) # Needed for performance - self.sortByColumn(0, Qt.AscendingOrder) - - # Only show the actions for collaps/expand all entries in the widget - # For further information see spyder-ide/spyder#13178 - self.common_actions = self.common_actions[:2] - - # Signals - self.header().sectionClicked.connect(self.sort_section) - - def activated(self, item): - """Double-click event.""" - itemdata = self.data.get(id(self.currentItem())) - if itemdata is not None: - filename, lineno, colno, colend = itemdata - self.sig_edit_goto_requested.emit( - filename, lineno, self.search_text, colno, colend - colno) - - def set_sorting(self, flag): - """Enable result sorting after search is complete.""" - self.sorting['status'] = flag - self.header().setSectionsClickable(flag == ON) - - @Slot(int) - def sort_section(self, idx): - self.setSortingEnabled(True) - - def clicked(self, item): - """Click event.""" - if isinstance(item, FileMatchItem): - if item.isExpanded(): - self.collapseItem(item) - else: - self.expandItem(item) - else: - self.activated(item) - - def clear_title(self, search_text): - self.font = get_font() - self.clear() - self.setSortingEnabled(False) - self.num_files = 0 - self.data = {} - self.files = {} - self.set_sorting(OFF) - self.search_text = search_text - title = "'%s' - " % search_text - text = _('String not found') - self.set_title(title + text) - - @Slot(object) - def append_file_result(self, filename): - """Real-time update of file items.""" - if len(self.data) < self.max_results: - self.files[filename] = item = FileMatchItem( - self, - self.path, - filename, - self.sorting, - self.text_color - ) - - item.setExpanded(True) - self.num_files += 1 - - item_text = osp.join(item.rel_dirname, item.filename) - if len(item_text) > len(self.longest_file_item): - self.longest_file_item = item_text - - @Slot(object, object) - def append_result(self, items, title): - """Real-time update of line items.""" - if len(self.data) >= self.max_results: - self.set_title(_('Maximum number of results reached! Try ' - 'narrowing the search.')) - self.sig_max_results_reached.emit() - return - - available = self.max_results - len(self.data) - if available < len(items): - items = items[:available] - - self.setUpdatesEnabled(False) - self.set_title(title) - for item in items: - filename, lineno, colno, line, match_end = item - file_item = self.files.get(filename, None) - if file_item: - item = LineMatchItem(file_item, lineno, colno, line, - self.font, self.text_color) - self.data[id(item)] = (filename, lineno, colno, match_end) - - if len(item.plain_match) > len(self.longest_line_item): - self.longest_line_item = item.plain_match - - self.setUpdatesEnabled(True) - - def set_max_results(self, value): - """Set maximum amount of results to add.""" - self.max_results = value - - def set_path(self, path): - """Set path where the search is performed.""" - self.path = path - - def set_width(self): - """Set widget width according to its longest item.""" - # File item width - file_item_size = self.fontMetrics().size( - Qt.TextSingleLine, - self.longest_file_item - ) - file_item_width = file_item_size.width() - - # Line item width - metrics = QFontMetrics(self.font) - line_item_chars = len(self.longest_line_item) - if line_item_chars >= MAX_RESULT_LENGTH: - line_item_chars = MAX_RESULT_LENGTH + len(ELLIPSIS) + 1 - line_item_width = line_item_chars * metrics.width('W') - - # Select width - if file_item_width > line_item_width: - width = file_item_width - else: - width = line_item_width - - # Increase width a bit to not be too near to the edge - self.itemDelegate().width = width + 10 +# -*- coding: utf-8 -*- +# +# Copyright © Spyder Project Contributors +# Licensed under the terms of the MIT License +# (see spyder/__init__.py for details) + +"""Results browser.""" + +# Standard library imports +import os.path as osp + +# Third party imports +from qtpy.QtCore import QPoint, QSize, Qt, Signal, Slot +from qtpy.QtGui import (QAbstractTextDocumentLayout, QColor, QBrush, + QFontMetrics, QPalette, QTextDocument) +from qtpy.QtWidgets import (QApplication, QStyle, QStyledItemDelegate, + QStyleOptionViewItem, QTreeWidgetItem) + +# Local imports +from spyder.api.translations import get_translation +from spyder.config.gui import get_font +from spyder.plugins.findinfiles.widgets.search_thread import ( + ELLIPSIS, MAX_RESULT_LENGTH) +from spyder.utils import icon_manager as ima +from spyder.utils.palette import QStylePalette +from spyder.widgets.onecolumntree import OneColumnTree + +# Localization +_ = get_translation('spyder') + + +# ---- Constants +# ---------------------------------------------------------------------------- +ON = 'on' +OFF = 'off' + + +# ---- Items +# ---------------------------------------------------------------------------- +class LineMatchItem(QTreeWidgetItem): + + def __init__(self, parent, lineno, colno, match, font, text_color): + self.lineno = lineno + self.colno = colno + self.match = match['formatted_text'] + self.plain_match = match['text'] + self.text_color = text_color + self.font = font + super().__init__(parent, [self.__repr__()], QTreeWidgetItem.Type) + + def __repr__(self): + match = str(self.match).rstrip() + _str = ( + f"" + f"

" + f'  ' + f"{self.lineno} ({self.colno}): " + f"{match}

" + ) + return _str + + def __unicode__(self): + return self.__repr__() + + def __str__(self): + return self.__repr__() + + def __lt__(self, x): + return self.lineno < x.lineno + + def __ge__(self, x): + return self.lineno >= x.lineno + + +class FileMatchItem(QTreeWidgetItem): + + def __init__(self, parent, path, filename, sorting, text_color): + + self.sorting = sorting + self.filename = osp.basename(filename) + + # Get relative dirname according to the path we're searching in. + dirname = osp.dirname(filename) + rel_dirname = dirname.split(path)[1] + if rel_dirname.startswith(osp.sep): + rel_dirname = rel_dirname[1:] + self.rel_dirname = rel_dirname + + title = ( + f'' + f'{osp.basename(filename)}' + f'   ' + f'' + f'{self.rel_dirname}' + f'' + ) + + super().__init__(parent, [title], QTreeWidgetItem.Type) + + self.setIcon(0, ima.get_icon_by_extension_or_type(filename, 1.0)) + self.setToolTip(0, filename) + + def __lt__(self, x): + if self.sorting['status'] == ON: + return self.filename < x.filename + else: + return False + + def __ge__(self, x): + if self.sorting['status'] == ON: + return self.filename >= x.filename + else: + return False + + +# ---- Browser +# ---------------------------------------------------------------------------- +class ItemDelegate(QStyledItemDelegate): + + def __init__(self, parent): + super().__init__(parent) + self._margin = None + self._background_color = QColor(QStylePalette.COLOR_BACKGROUND_3) + self.width = 0 + + def paint(self, painter, option, index): + options = QStyleOptionViewItem(option) + self.initStyleOption(options, index) + style = (QApplication.style() if options.widget is None + else options.widget.style()) + + # Set background color for selected and hovered items. + # Inspired by: + # - https://stackoverflow.com/a/43253004/438386 + # - https://stackoverflow.com/a/27274233/438386 + + # This is commented for now until we find a way to correctly colorize + # the entire line with a single color. + # if options.state & QStyle.State_Selected: + # # This only applies when the selected item doesn't have focus + # if not (options.state & QStyle.State_HasFocus): + # options.palette.setBrush( + # QPalette.Highlight, + # QBrush(self._background_color) + # ) + + if options.state & QStyle.State_MouseOver: + painter.fillRect(option.rect, self._background_color) + + # Set text + doc = QTextDocument() + text = options.text + doc.setHtml(text) + doc.setDocumentMargin(0) + + # This needs to be an empty string to avoid overlapping the + # normal text of the QTreeWidgetItem + options.text = "" + style.drawControl(QStyle.CE_ItemViewItem, options, painter) + + ctx = QAbstractTextDocumentLayout.PaintContext() + + textRect = style.subElementRect(QStyle.SE_ItemViewItemText, + options, None) + painter.save() + + painter.translate(textRect.topLeft() + QPoint(0, 4)) + doc.documentLayout().draw(painter, ctx) + painter.restore() + + def sizeHint(self, option, index): + options = QStyleOptionViewItem(option) + self.initStyleOption(options, index) + doc = QTextDocument() + doc.setHtml(options.text) + doc.setTextWidth(options.rect.width()) + size = QSize(self.width, int(doc.size().height())) + return size + + +class ResultsBrowser(OneColumnTree): + sig_edit_goto_requested = Signal(str, int, str, int, int) + sig_max_results_reached = Signal() + + def __init__(self, parent, text_color, max_results=1000): + super().__init__(parent) + self.search_text = None + self.results = None + self.max_results = max_results + self.total_matches = None + self.error_flag = None + self.completed = None + self.sorting = {} + self.font = get_font() + self.data = None + self.files = None + self.root_items = None + self.text_color = text_color + self.path = None + self.longest_file_item = '' + self.longest_line_item = '' + + # Setup + self.set_title('') + self.set_sorting(OFF) + self.setSortingEnabled(False) + self.setItemDelegate(ItemDelegate(self)) + self.setUniformRowHeights(True) # Needed for performance + self.sortByColumn(0, Qt.AscendingOrder) + + # Only show the actions for collaps/expand all entries in the widget + # For further information see spyder-ide/spyder#13178 + self.common_actions = self.common_actions[:2] + + # Signals + self.header().sectionClicked.connect(self.sort_section) + + def activated(self, item): + """Double-click event.""" + itemdata = self.data.get(id(self.currentItem())) + if itemdata is not None: + filename, lineno, colno, colend = itemdata + self.sig_edit_goto_requested.emit( + filename, lineno, self.search_text, colno, colend - colno) + + def set_sorting(self, flag): + """Enable result sorting after search is complete.""" + self.sorting['status'] = flag + self.header().setSectionsClickable(flag == ON) + + @Slot(int) + def sort_section(self, idx): + self.setSortingEnabled(True) + + def clicked(self, item): + """Click event.""" + if isinstance(item, FileMatchItem): + if item.isExpanded(): + self.collapseItem(item) + else: + self.expandItem(item) + else: + self.activated(item) + + def clear_title(self, search_text): + self.font = get_font() + self.clear() + self.setSortingEnabled(False) + self.num_files = 0 + self.data = {} + self.files = {} + self.set_sorting(OFF) + self.search_text = search_text + title = "'%s' - " % search_text + text = _('String not found') + self.set_title(title + text) + + @Slot(object) + def append_file_result(self, filename): + """Real-time update of file items.""" + if len(self.data) < self.max_results: + self.files[filename] = item = FileMatchItem( + self, + self.path, + filename, + self.sorting, + self.text_color + ) + + item.setExpanded(True) + self.num_files += 1 + + item_text = osp.join(item.rel_dirname, item.filename) + if len(item_text) > len(self.longest_file_item): + self.longest_file_item = item_text + + @Slot(object, object) + def append_result(self, items, title): + """Real-time update of line items.""" + if len(self.data) >= self.max_results: + self.set_title(_('Maximum number of results reached! Try ' + 'narrowing the search.')) + self.sig_max_results_reached.emit() + return + + available = self.max_results - len(self.data) + if available < len(items): + items = items[:available] + + self.setUpdatesEnabled(False) + self.set_title(title) + for item in items: + filename, lineno, colno, line, match_end = item + file_item = self.files.get(filename, None) + if file_item: + item = LineMatchItem(file_item, lineno, colno, line, + self.font, self.text_color) + self.data[id(item)] = (filename, lineno, colno, match_end) + + if len(item.plain_match) > len(self.longest_line_item): + self.longest_line_item = item.plain_match + + self.setUpdatesEnabled(True) + + def set_max_results(self, value): + """Set maximum amount of results to add.""" + self.max_results = value + + def set_path(self, path): + """Set path where the search is performed.""" + self.path = path + + def set_width(self): + """Set widget width according to its longest item.""" + # File item width + file_item_size = self.fontMetrics().size( + Qt.TextSingleLine, + self.longest_file_item + ) + file_item_width = file_item_size.width() + + # Line item width + metrics = QFontMetrics(self.font) + line_item_chars = len(self.longest_line_item) + if line_item_chars >= MAX_RESULT_LENGTH: + line_item_chars = MAX_RESULT_LENGTH + len(ELLIPSIS) + 1 + line_item_width = line_item_chars * metrics.width('W') + + # Select width + if file_item_width > line_item_width: + width = file_item_width + else: + width = line_item_width + + # Increase width a bit to not be too near to the edge + self.itemDelegate().width = width + 10 diff --git a/spyder/plugins/help/api.py b/spyder/plugins/help/api.py index 6efb03dac53..c1e31cd399a 100644 --- a/spyder/plugins/help/api.py +++ b/spyder/plugins/help/api.py @@ -1,15 +1,15 @@ -# -*- coding: utf-8 -*- -# -# Copyright © Spyder Project Contributors -# Licensed under the terms of the MIT License -# (see spyder/__init__.py for details) - -""" -Help Plugin API. -""" - -# Local imports -from spyder.plugins.help.plugin import HelpActions -from spyder.plugins.help.widgets import (HelpWidgetActions, - HelpWidgetMainToolbarSections, - HelpWidgetOptionsMenuSections) +# -*- coding: utf-8 -*- +# +# Copyright © Spyder Project Contributors +# Licensed under the terms of the MIT License +# (see spyder/__init__.py for details) + +""" +Help Plugin API. +""" + +# Local imports +from spyder.plugins.help.plugin import HelpActions +from spyder.plugins.help.widgets import (HelpWidgetActions, + HelpWidgetMainToolbarSections, + HelpWidgetOptionsMenuSections) diff --git a/spyder/plugins/help/plugin.py b/spyder/plugins/help/plugin.py index 7c3aa4e30fe..e9fc5d67484 100644 --- a/spyder/plugins/help/plugin.py +++ b/spyder/plugins/help/plugin.py @@ -1,375 +1,375 @@ -# -*- coding: utf-8 -*- -# -# Copyright © Spyder Project Contributors -# Licensed under the terms of the MIT License -# (see spyder/__init__.py for details) - -""" -Help Plugin. -""" - -# Standard library imports -import os - -# Third party imports -from qtpy.QtCore import Signal - -# Local imports -from spyder.api.exceptions import SpyderAPIError -from spyder.api.plugins import Plugins, SpyderDockablePlugin -from spyder.api.plugin_registration.decorators import ( - on_plugin_available, on_plugin_teardown) -from spyder.api.translations import get_translation -from spyder.config.base import get_conf_path -from spyder.config.fonts import DEFAULT_SMALL_DELTA -from spyder.plugins.help.confpage import HelpConfigPage -from spyder.plugins.help.widgets import HelpWidget - -# Localization -_ = get_translation('spyder') - - -class HelpActions: - # Documentation related - ShowSpyderTutorialAction = "spyder_tutorial_action" - - -class Help(SpyderDockablePlugin): - """ - Docstrings viewer widget. - """ - NAME = 'help' - REQUIRES = [Plugins.Preferences, Plugins.Console, Plugins.Editor] - OPTIONAL = [Plugins.IPythonConsole, Plugins.Shortcuts, Plugins.MainMenu] - TABIFY = Plugins.VariableExplorer - WIDGET_CLASS = HelpWidget - CONF_SECTION = NAME - CONF_WIDGET_CLASS = HelpConfigPage - CONF_FILE = False - LOG_PATH = get_conf_path(CONF_SECTION) - FONT_SIZE_DELTA = DEFAULT_SMALL_DELTA - DISABLE_ACTIONS_WHEN_HIDDEN = False - - # Signals - sig_focus_changed = Signal() # TODO: What triggers this? - - sig_render_started = Signal() - """This signal is emitted to inform a help text rendering has started.""" - - sig_render_finished = Signal() - """This signal is emitted to inform a help text rendering has finished.""" - - # --- SpyderDocakblePlugin API - # ----------------------------------------------------------------------- - @staticmethod - def get_name(): - return _('Help') - - def get_description(self): - return _( - 'Get rich text documentation from the editor and the console') - - def get_icon(self): - return self.create_icon('help') - - def on_initialize(self): - widget = self.get_widget() - - # Expose widget signals on the plugin - widget.sig_render_started.connect(self.sig_render_started) - widget.sig_render_finished.connect(self.sig_render_finished) - - # self.sig_focus_changed.connect(self.main.plugin_focus_changed) - widget.set_history(self.load_history()) - widget.sig_item_found.connect(self.save_history) - - self.tutorial_action = self.create_action( - HelpActions.ShowSpyderTutorialAction, - text=_("Spyder tutorial"), - triggered=self.show_tutorial, - register_shortcut=False, - ) - - @on_plugin_available(plugin=Plugins.Console) - def on_console_available(self): - widget = self.get_widget() - internal_console = self.get_plugin(Plugins.Console) - internal_console.sig_help_requested.connect(self.set_object_text) - widget.set_internal_console(internal_console) - - @on_plugin_available(plugin=Plugins.Editor) - def on_editor_available(self): - editor = self.get_plugin(Plugins.Editor) - editor.sig_help_requested.connect(self.set_editor_doc) - - @on_plugin_available(plugin=Plugins.IPythonConsole) - def on_ipython_console_available(self): - ipyconsole = self.get_plugin(Plugins.IPythonConsole) - - ipyconsole.sig_shellwidget_changed.connect(self.set_shellwidget) - ipyconsole.sig_shellwidget_created.connect(self.set_shellwidget) - ipyconsole.sig_render_plain_text_requested.connect( - self.show_plain_text) - ipyconsole.sig_render_rich_text_requested.connect( - self.show_rich_text) - - ipyconsole.sig_help_requested.connect(self.set_object_text) - - @on_plugin_available(plugin=Plugins.Preferences) - def on_preferences_available(self): - preferences = self.get_plugin(Plugins.Preferences) - preferences.register_plugin_preferences(self) - - @on_plugin_available(plugin=Plugins.Shortcuts) - def on_shortcuts_available(self): - shortcuts = self.get_plugin(Plugins.Shortcuts) - - # See: spyder-ide/spyder#6992 - shortcuts.sig_shortcuts_updated.connect(self.show_intro_message) - - if self.is_plugin_available(Plugins.MainMenu): - self._setup_menus() - - @on_plugin_available(plugin=Plugins.MainMenu) - def on_main_menu_available(self): - if self.is_plugin_enabled(Plugins.Shortcuts): - if self.is_plugin_available(Plugins.Shortcuts): - self._setup_menus() - else: - self._setup_menus() - - @on_plugin_teardown(plugin=Plugins.Console) - def on_console_teardown(self): - widget = self.get_widget() - internal_console = self.get_plugin(Plugins.Console) - internal_console.sig_help_requested.disconnect(self.set_object_text) - widget.set_internal_console(None) - - @on_plugin_teardown(plugin=Plugins.Editor) - def on_editor_teardown(self): - editor = self.get_plugin(Plugins.Editor) - editor.sig_help_requested.disconnect(self.set_editor_doc) - - @on_plugin_teardown(plugin=Plugins.IPythonConsole) - def on_ipython_console_teardown(self): - ipyconsole = self.get_plugin(Plugins.IPythonConsole) - - ipyconsole.sig_shellwidget_changed.disconnect(self.set_shellwidget) - ipyconsole.sig_shellwidget_created.disconnect( - self.set_shellwidget) - ipyconsole.sig_render_plain_text_requested.disconnect( - self.show_plain_text) - ipyconsole.sig_render_rich_text_requested.disconnect( - self.show_rich_text) - - ipyconsole.sig_help_requested.disconnect(self.set_object_text) - - @on_plugin_teardown(plugin=Plugins.Preferences) - def on_preferences_teardown(self): - preferences = self.get_plugin(Plugins.Preferences) - preferences.deregister_plugin_preferences(self) - - @on_plugin_teardown(plugin=Plugins.Shortcuts) - def on_shortcuts_teardown(self): - shortcuts = self.get_plugin(Plugins.Shortcuts) - shortcuts.sig_shortcuts_updated.disconnect(self.show_intro_message) - - @on_plugin_teardown(plugin=Plugins.MainMenu) - def on_main_menu_teardown(self): - self._remove_menus() - - def update_font(self): - color_scheme = self.get_color_scheme() - font = self.get_font() - rich_font = self.get_font(rich_text=True) - - widget = self.get_widget() - widget.set_plain_text_font(font, color_scheme=color_scheme) - widget.set_rich_text_font(rich_font, font) - widget.set_plain_text_color_scheme(color_scheme) - - def on_close(self, cancelable=False): - self.save_history() - return True - - def apply_conf(self, options_set, notify=False): - super().apply_conf(options_set) - - # To make auto-connection changes take place instantly - try: - editor = self.get_plugin(Plugins.Editor) - editor.apply_plugin_settings({'connect_to_oi'}) - except SpyderAPIError: - pass - - def on_mainwindow_visible(self): - # Raise plugin the first time Spyder starts - if self.get_conf('show_first_time', default=True): - self.dockwidget.raise_() - self.set_conf('show_first_time', False) - - # --- Private API - # ------------------------------------------------------------------------ - def _setup_menus(self): - mainmenu = self.get_plugin(Plugins.MainMenu) - shortcuts = self.get_plugin(Plugins.Shortcuts) - shortcuts_summary_action = None - if shortcuts: - from spyder.plugins.shortcuts.plugin import ShortcutActions - shortcuts_summary_action = ShortcutActions.ShortcutSummaryAction - if mainmenu: - from spyder.plugins.mainmenu.api import ( - ApplicationMenus, HelpMenuSections) - # Documentation actions - mainmenu.add_item_to_application_menu( - self.tutorial_action, - menu_id=ApplicationMenus.Help, - section=HelpMenuSections.Documentation, - before=shortcuts_summary_action, - before_section=HelpMenuSections.Support) - - def _remove_menus(self): - from spyder.plugins.mainmenu.api import ApplicationMenus - mainmenu = self.get_plugin(Plugins.MainMenu) - mainmenu.remove_item_from_application_menu( - HelpActions.ShowSpyderTutorialAction, - menu_id=ApplicationMenus.Help) - - # --- Public API - # ------------------------------------------------------------------------ - def set_shellwidget(self, shellwidget): - """ - Set IPython Console `shelwidget` as the current shellwidget. - - Parameters - ---------- - shellwidget: spyder.plugins.ipyconsole.widgets.shell.ShellWidget - The shell widget that is going to be connected to Help. - """ - shellwidget._control.set_help_enabled( - self.get_conf('connect/ipython_console')) - self.get_widget().set_shell(shellwidget) - - def load_history(self, obj=None): - """ - Load history from a text file in the user configuration directory. - """ - if os.path.isfile(self.LOG_PATH): - with open(self.LOG_PATH, 'r') as fh: - lines = fh.read().split('\n') - - history = [line.replace('\n', '') for line in lines] - else: - history = [] - - return history - - def save_history(self): - """ - Save history to a text file in the user configuration directory. - """ - # Don't fail when saving search history to disk - # See spyder-ide/spyder#8878 and spyder-ide/spyder#6864 - try: - search_history = '\n'.join(self.get_widget().get_history()) - with open(self.LOG_PATH, 'w') as fh: - fh.write(search_history) - except (UnicodeEncodeError, UnicodeDecodeError, EnvironmentError): - pass - - def show_tutorial(self): - """Show the Spyder tutorial.""" - self.switch_to_plugin() - self.get_widget().show_tutorial() - - def show_intro_message(self): - """Show the IPython introduction message.""" - self.get_widget().show_intro_message() - - def show_rich_text(self, text, collapse=False, img_path=''): - """ - Show help in rich mode. - - Parameters - ---------- - text: str - Plain text to display. - collapse: bool, optional - Show collapsable sections as collapsed/expanded. Default is False. - img_path: str, optional - Path to folder with additional images needed to correctly - display the rich text help. Default is ''. - """ - self.switch_to_plugin() - self.get_widget().show_rich_text(text, collapse=collapse, - img_path=img_path) - - def show_plain_text(self, text): - """ - Show help in plain mode. - - Parameters - ---------- - text: str - Plain text to display. - """ - self.switch_to_plugin() - self.get_widget().show_plain_text(text) - - def set_object_text(self, options_dict): - """ - Set object's name in Help's combobox. - - Parameters - ---------- - options_dict: dict - Dictionary of data. See the example for the expected keys. - - Examples - -------- - >>> help_data = { - 'name': str, - 'force_refresh': bool, - } - - See Also - -------- - :py:meth:spyder.widgets.mixins.GetHelpMixin.show_object_info - """ - self.switch_to_plugin() - self.get_widget().set_object_text( - options_dict['name'], - ignore_unknown=options_dict['ignore_unknown'], - ) - - def set_editor_doc(self, help_data): - """ - Set content for help data sent from the editor. - - Parameters - ---------- - help_data: dict - Dictionary of data. See the example for the expected keys. - - Examples - -------- - >>> help_data = { - 'obj_text': str, - 'name': str, - 'argspec': str, - 'note': str, - 'docstring': str, - 'force_refresh': bool, - 'path': str, - } - - See Also - -------- - :py:meth:spyder.plugins.editor.widgets.editor.EditorStack.send_to_help - """ - force_refresh = help_data.pop('force_refresh', False) - self.switch_to_plugin() - self.get_widget().set_editor_doc( - help_data, - force_refresh=force_refresh, - ) +# -*- coding: utf-8 -*- +# +# Copyright © Spyder Project Contributors +# Licensed under the terms of the MIT License +# (see spyder/__init__.py for details) + +""" +Help Plugin. +""" + +# Standard library imports +import os + +# Third party imports +from qtpy.QtCore import Signal + +# Local imports +from spyder.api.exceptions import SpyderAPIError +from spyder.api.plugins import Plugins, SpyderDockablePlugin +from spyder.api.plugin_registration.decorators import ( + on_plugin_available, on_plugin_teardown) +from spyder.api.translations import get_translation +from spyder.config.base import get_conf_path +from spyder.config.fonts import DEFAULT_SMALL_DELTA +from spyder.plugins.help.confpage import HelpConfigPage +from spyder.plugins.help.widgets import HelpWidget + +# Localization +_ = get_translation('spyder') + + +class HelpActions: + # Documentation related + ShowSpyderTutorialAction = "spyder_tutorial_action" + + +class Help(SpyderDockablePlugin): + """ + Docstrings viewer widget. + """ + NAME = 'help' + REQUIRES = [Plugins.Preferences, Plugins.Console, Plugins.Editor] + OPTIONAL = [Plugins.IPythonConsole, Plugins.Shortcuts, Plugins.MainMenu] + TABIFY = Plugins.VariableExplorer + WIDGET_CLASS = HelpWidget + CONF_SECTION = NAME + CONF_WIDGET_CLASS = HelpConfigPage + CONF_FILE = False + LOG_PATH = get_conf_path(CONF_SECTION) + FONT_SIZE_DELTA = DEFAULT_SMALL_DELTA + DISABLE_ACTIONS_WHEN_HIDDEN = False + + # Signals + sig_focus_changed = Signal() # TODO: What triggers this? + + sig_render_started = Signal() + """This signal is emitted to inform a help text rendering has started.""" + + sig_render_finished = Signal() + """This signal is emitted to inform a help text rendering has finished.""" + + # --- SpyderDocakblePlugin API + # ----------------------------------------------------------------------- + @staticmethod + def get_name(): + return _('Help') + + def get_description(self): + return _( + 'Get rich text documentation from the editor and the console') + + def get_icon(self): + return self.create_icon('help') + + def on_initialize(self): + widget = self.get_widget() + + # Expose widget signals on the plugin + widget.sig_render_started.connect(self.sig_render_started) + widget.sig_render_finished.connect(self.sig_render_finished) + + # self.sig_focus_changed.connect(self.main.plugin_focus_changed) + widget.set_history(self.load_history()) + widget.sig_item_found.connect(self.save_history) + + self.tutorial_action = self.create_action( + HelpActions.ShowSpyderTutorialAction, + text=_("Spyder tutorial"), + triggered=self.show_tutorial, + register_shortcut=False, + ) + + @on_plugin_available(plugin=Plugins.Console) + def on_console_available(self): + widget = self.get_widget() + internal_console = self.get_plugin(Plugins.Console) + internal_console.sig_help_requested.connect(self.set_object_text) + widget.set_internal_console(internal_console) + + @on_plugin_available(plugin=Plugins.Editor) + def on_editor_available(self): + editor = self.get_plugin(Plugins.Editor) + editor.sig_help_requested.connect(self.set_editor_doc) + + @on_plugin_available(plugin=Plugins.IPythonConsole) + def on_ipython_console_available(self): + ipyconsole = self.get_plugin(Plugins.IPythonConsole) + + ipyconsole.sig_shellwidget_changed.connect(self.set_shellwidget) + ipyconsole.sig_shellwidget_created.connect(self.set_shellwidget) + ipyconsole.sig_render_plain_text_requested.connect( + self.show_plain_text) + ipyconsole.sig_render_rich_text_requested.connect( + self.show_rich_text) + + ipyconsole.sig_help_requested.connect(self.set_object_text) + + @on_plugin_available(plugin=Plugins.Preferences) + def on_preferences_available(self): + preferences = self.get_plugin(Plugins.Preferences) + preferences.register_plugin_preferences(self) + + @on_plugin_available(plugin=Plugins.Shortcuts) + def on_shortcuts_available(self): + shortcuts = self.get_plugin(Plugins.Shortcuts) + + # See: spyder-ide/spyder#6992 + shortcuts.sig_shortcuts_updated.connect(self.show_intro_message) + + if self.is_plugin_available(Plugins.MainMenu): + self._setup_menus() + + @on_plugin_available(plugin=Plugins.MainMenu) + def on_main_menu_available(self): + if self.is_plugin_enabled(Plugins.Shortcuts): + if self.is_plugin_available(Plugins.Shortcuts): + self._setup_menus() + else: + self._setup_menus() + + @on_plugin_teardown(plugin=Plugins.Console) + def on_console_teardown(self): + widget = self.get_widget() + internal_console = self.get_plugin(Plugins.Console) + internal_console.sig_help_requested.disconnect(self.set_object_text) + widget.set_internal_console(None) + + @on_plugin_teardown(plugin=Plugins.Editor) + def on_editor_teardown(self): + editor = self.get_plugin(Plugins.Editor) + editor.sig_help_requested.disconnect(self.set_editor_doc) + + @on_plugin_teardown(plugin=Plugins.IPythonConsole) + def on_ipython_console_teardown(self): + ipyconsole = self.get_plugin(Plugins.IPythonConsole) + + ipyconsole.sig_shellwidget_changed.disconnect(self.set_shellwidget) + ipyconsole.sig_shellwidget_created.disconnect( + self.set_shellwidget) + ipyconsole.sig_render_plain_text_requested.disconnect( + self.show_plain_text) + ipyconsole.sig_render_rich_text_requested.disconnect( + self.show_rich_text) + + ipyconsole.sig_help_requested.disconnect(self.set_object_text) + + @on_plugin_teardown(plugin=Plugins.Preferences) + def on_preferences_teardown(self): + preferences = self.get_plugin(Plugins.Preferences) + preferences.deregister_plugin_preferences(self) + + @on_plugin_teardown(plugin=Plugins.Shortcuts) + def on_shortcuts_teardown(self): + shortcuts = self.get_plugin(Plugins.Shortcuts) + shortcuts.sig_shortcuts_updated.disconnect(self.show_intro_message) + + @on_plugin_teardown(plugin=Plugins.MainMenu) + def on_main_menu_teardown(self): + self._remove_menus() + + def update_font(self): + color_scheme = self.get_color_scheme() + font = self.get_font() + rich_font = self.get_font(rich_text=True) + + widget = self.get_widget() + widget.set_plain_text_font(font, color_scheme=color_scheme) + widget.set_rich_text_font(rich_font, font) + widget.set_plain_text_color_scheme(color_scheme) + + def on_close(self, cancelable=False): + self.save_history() + return True + + def apply_conf(self, options_set, notify=False): + super().apply_conf(options_set) + + # To make auto-connection changes take place instantly + try: + editor = self.get_plugin(Plugins.Editor) + editor.apply_plugin_settings({'connect_to_oi'}) + except SpyderAPIError: + pass + + def on_mainwindow_visible(self): + # Raise plugin the first time Spyder starts + if self.get_conf('show_first_time', default=True): + self.dockwidget.raise_() + self.set_conf('show_first_time', False) + + # --- Private API + # ------------------------------------------------------------------------ + def _setup_menus(self): + mainmenu = self.get_plugin(Plugins.MainMenu) + shortcuts = self.get_plugin(Plugins.Shortcuts) + shortcuts_summary_action = None + if shortcuts: + from spyder.plugins.shortcuts.plugin import ShortcutActions + shortcuts_summary_action = ShortcutActions.ShortcutSummaryAction + if mainmenu: + from spyder.plugins.mainmenu.api import ( + ApplicationMenus, HelpMenuSections) + # Documentation actions + mainmenu.add_item_to_application_menu( + self.tutorial_action, + menu_id=ApplicationMenus.Help, + section=HelpMenuSections.Documentation, + before=shortcuts_summary_action, + before_section=HelpMenuSections.Support) + + def _remove_menus(self): + from spyder.plugins.mainmenu.api import ApplicationMenus + mainmenu = self.get_plugin(Plugins.MainMenu) + mainmenu.remove_item_from_application_menu( + HelpActions.ShowSpyderTutorialAction, + menu_id=ApplicationMenus.Help) + + # --- Public API + # ------------------------------------------------------------------------ + def set_shellwidget(self, shellwidget): + """ + Set IPython Console `shelwidget` as the current shellwidget. + + Parameters + ---------- + shellwidget: spyder.plugins.ipyconsole.widgets.shell.ShellWidget + The shell widget that is going to be connected to Help. + """ + shellwidget._control.set_help_enabled( + self.get_conf('connect/ipython_console')) + self.get_widget().set_shell(shellwidget) + + def load_history(self, obj=None): + """ + Load history from a text file in the user configuration directory. + """ + if os.path.isfile(self.LOG_PATH): + with open(self.LOG_PATH, 'r') as fh: + lines = fh.read().split('\n') + + history = [line.replace('\n', '') for line in lines] + else: + history = [] + + return history + + def save_history(self): + """ + Save history to a text file in the user configuration directory. + """ + # Don't fail when saving search history to disk + # See spyder-ide/spyder#8878 and spyder-ide/spyder#6864 + try: + search_history = '\n'.join(self.get_widget().get_history()) + with open(self.LOG_PATH, 'w') as fh: + fh.write(search_history) + except (UnicodeEncodeError, UnicodeDecodeError, EnvironmentError): + pass + + def show_tutorial(self): + """Show the Spyder tutorial.""" + self.switch_to_plugin() + self.get_widget().show_tutorial() + + def show_intro_message(self): + """Show the IPython introduction message.""" + self.get_widget().show_intro_message() + + def show_rich_text(self, text, collapse=False, img_path=''): + """ + Show help in rich mode. + + Parameters + ---------- + text: str + Plain text to display. + collapse: bool, optional + Show collapsable sections as collapsed/expanded. Default is False. + img_path: str, optional + Path to folder with additional images needed to correctly + display the rich text help. Default is ''. + """ + self.switch_to_plugin() + self.get_widget().show_rich_text(text, collapse=collapse, + img_path=img_path) + + def show_plain_text(self, text): + """ + Show help in plain mode. + + Parameters + ---------- + text: str + Plain text to display. + """ + self.switch_to_plugin() + self.get_widget().show_plain_text(text) + + def set_object_text(self, options_dict): + """ + Set object's name in Help's combobox. + + Parameters + ---------- + options_dict: dict + Dictionary of data. See the example for the expected keys. + + Examples + -------- + >>> help_data = { + 'name': str, + 'force_refresh': bool, + } + + See Also + -------- + :py:meth:spyder.widgets.mixins.GetHelpMixin.show_object_info + """ + self.switch_to_plugin() + self.get_widget().set_object_text( + options_dict['name'], + ignore_unknown=options_dict['ignore_unknown'], + ) + + def set_editor_doc(self, help_data): + """ + Set content for help data sent from the editor. + + Parameters + ---------- + help_data: dict + Dictionary of data. See the example for the expected keys. + + Examples + -------- + >>> help_data = { + 'obj_text': str, + 'name': str, + 'argspec': str, + 'note': str, + 'docstring': str, + 'force_refresh': bool, + 'path': str, + } + + See Also + -------- + :py:meth:spyder.plugins.editor.widgets.editor.EditorStack.send_to_help + """ + force_refresh = help_data.pop('force_refresh', False) + self.switch_to_plugin() + self.get_widget().set_editor_doc( + help_data, + force_refresh=force_refresh, + ) diff --git a/spyder/plugins/help/utils/__init__.py b/spyder/plugins/help/utils/__init__.py index b40f03ab67e..ab744c1b77d 100644 --- a/spyder/plugins/help/utils/__init__.py +++ b/spyder/plugins/help/utils/__init__.py @@ -1,20 +1,20 @@ -# -*- coding: utf-8 -*- -# ----------------------------------------------------------------------------- -# Copyright (c) 2009- Spyder Project Contributors and others (see LICENSE.txt) -# -# Licensed under the terms of the MIT and other licenses where noted -# (see LICENSE.txt in this directory and NOTICE.txt in the root for details) -# ----------------------------------------------------------------------------- - -""" -spyder.plugins.help.utils -================= - -Configuration files for the Help plugin rich text mode. - -See their headers, LICENSE.txt in this directory or NOTICE.txt for licenses. -""" - -import sys -from spyder.config.base import get_module_source_path -sys.path.insert(0, get_module_source_path(__name__)) +# -*- coding: utf-8 -*- +# ----------------------------------------------------------------------------- +# Copyright (c) 2009- Spyder Project Contributors and others (see LICENSE.txt) +# +# Licensed under the terms of the MIT and other licenses where noted +# (see LICENSE.txt in this directory and NOTICE.txt in the root for details) +# ----------------------------------------------------------------------------- + +""" +spyder.plugins.help.utils +================= + +Configuration files for the Help plugin rich text mode. + +See their headers, LICENSE.txt in this directory or NOTICE.txt for licenses. +""" + +import sys +from spyder.config.base import get_module_source_path +sys.path.insert(0, get_module_source_path(__name__)) diff --git a/spyder/plugins/history/plugin.py b/spyder/plugins/history/plugin.py index 6d67a9fb356..91dc86aade0 100644 --- a/spyder/plugins/history/plugin.py +++ b/spyder/plugins/history/plugin.py @@ -1,137 +1,137 @@ -# -*- coding: utf-8 -*- -# -# Copyright © Spyder Project Contributors -# Licensed under the terms of the MIT License -# (see spyder/__init__.py for details) - -""" -Console History Plugin. -""" - -# Third party imports -from qtpy.QtCore import Signal - -# Local imports -from spyder.api.plugins import Plugins, SpyderDockablePlugin -from spyder.api.plugin_registration.decorators import ( - on_plugin_available, on_plugin_teardown) -from spyder.api.translations import get_translation -from spyder.config.base import get_conf_path -from spyder.plugins.history.confpage import HistoryConfigPage -from spyder.plugins.history.widgets import HistoryWidget - -# Localization -_ = get_translation('spyder') - - -class HistoryLog(SpyderDockablePlugin): - """ - History log plugin. - """ - - NAME = 'historylog' - REQUIRES = [Plugins.Preferences, Plugins.Console] - OPTIONAL = [Plugins.IPythonConsole] - TABIFY = Plugins.IPythonConsole - WIDGET_CLASS = HistoryWidget - CONF_SECTION = NAME - CONF_WIDGET_CLASS = HistoryConfigPage - CONF_FILE = False - - # --- Signals - # ------------------------------------------------------------------------ - sig_focus_changed = Signal() - """ - This signal is emitted when the focus of the code editor storing history - changes. - """ - - def __init__(self, parent=None, configuration=None): - """Initialization.""" - super().__init__(parent, configuration) - self.add_history(get_conf_path('history.py')) - - # --- SpyderDockablePlugin API - # ------------------------------------------------------------------------ - @staticmethod - def get_name(): - return _('History') - - def get_description(self): - return _('Provide command history for IPython Consoles') - - def get_icon(self): - return self.create_icon('history') - - def on_initialize(self): - widget = self.get_widget() - widget.sig_focus_changed.connect(self.sig_focus_changed) - - @on_plugin_available(plugin=Plugins.Preferences) - def on_preferences_available(self): - preferences = self.get_plugin(Plugins.Preferences) - preferences.register_plugin_preferences(self) - - @on_plugin_available(plugin=Plugins.Console) - def on_console_available(self): - console = self.get_plugin(Plugins.Console) - console.sig_refreshed.connect(self.refresh) - - @on_plugin_available(plugin=Plugins.IPythonConsole) - def on_ipyconsole_available(self): - ipyconsole = self.get_plugin(Plugins.IPythonConsole) - ipyconsole.sig_append_to_history_requested.connect( - self.append_to_history) - - @on_plugin_teardown(plugin=Plugins.Preferences) - def on_preferences_teardown(self): - preferences = self.get_plugin(Plugins.Preferences) - preferences.deregister_plugin_preferences(self) - - @on_plugin_teardown(plugin=Plugins.Console) - def on_console_teardown(self): - console = self.get_plugin(Plugins.Console) - console.sig_refreshed.disconnect(self.refresh) - - @on_plugin_teardown(plugin=Plugins.IPythonConsole) - def on_ipyconsole_teardown(self): - ipyconsole = self.get_plugin(Plugins.IPythonConsole) - ipyconsole.sig_append_to_history_requested.disconnect( - self.append_to_history) - - def update_font(self): - color_scheme = self.get_color_scheme() - font = self.get_font() - self.get_widget().update_font(font, color_scheme) - - # --- Plubic API - # ------------------------------------------------------------------------ - def refresh(self): - """ - Refresh main widget. - """ - self.get_widget().refresh() - - def add_history(self, filename): - """ - Create history file. - - Parameters - ---------- - filename: str - History file. - """ - self.get_widget().add_history(filename) - - def append_to_history(self, filename, command): - """ - Append command to history file. - - Parameters - ---------- - filename: str - History file. - command: str - Command to append to history file. - """ - self.get_widget().append_to_history(filename, command) +# -*- coding: utf-8 -*- +# +# Copyright © Spyder Project Contributors +# Licensed under the terms of the MIT License +# (see spyder/__init__.py for details) + +""" +Console History Plugin. +""" + +# Third party imports +from qtpy.QtCore import Signal + +# Local imports +from spyder.api.plugins import Plugins, SpyderDockablePlugin +from spyder.api.plugin_registration.decorators import ( + on_plugin_available, on_plugin_teardown) +from spyder.api.translations import get_translation +from spyder.config.base import get_conf_path +from spyder.plugins.history.confpage import HistoryConfigPage +from spyder.plugins.history.widgets import HistoryWidget + +# Localization +_ = get_translation('spyder') + + +class HistoryLog(SpyderDockablePlugin): + """ + History log plugin. + """ + + NAME = 'historylog' + REQUIRES = [Plugins.Preferences, Plugins.Console] + OPTIONAL = [Plugins.IPythonConsole] + TABIFY = Plugins.IPythonConsole + WIDGET_CLASS = HistoryWidget + CONF_SECTION = NAME + CONF_WIDGET_CLASS = HistoryConfigPage + CONF_FILE = False + + # --- Signals + # ------------------------------------------------------------------------ + sig_focus_changed = Signal() + """ + This signal is emitted when the focus of the code editor storing history + changes. + """ + + def __init__(self, parent=None, configuration=None): + """Initialization.""" + super().__init__(parent, configuration) + self.add_history(get_conf_path('history.py')) + + # --- SpyderDockablePlugin API + # ------------------------------------------------------------------------ + @staticmethod + def get_name(): + return _('History') + + def get_description(self): + return _('Provide command history for IPython Consoles') + + def get_icon(self): + return self.create_icon('history') + + def on_initialize(self): + widget = self.get_widget() + widget.sig_focus_changed.connect(self.sig_focus_changed) + + @on_plugin_available(plugin=Plugins.Preferences) + def on_preferences_available(self): + preferences = self.get_plugin(Plugins.Preferences) + preferences.register_plugin_preferences(self) + + @on_plugin_available(plugin=Plugins.Console) + def on_console_available(self): + console = self.get_plugin(Plugins.Console) + console.sig_refreshed.connect(self.refresh) + + @on_plugin_available(plugin=Plugins.IPythonConsole) + def on_ipyconsole_available(self): + ipyconsole = self.get_plugin(Plugins.IPythonConsole) + ipyconsole.sig_append_to_history_requested.connect( + self.append_to_history) + + @on_plugin_teardown(plugin=Plugins.Preferences) + def on_preferences_teardown(self): + preferences = self.get_plugin(Plugins.Preferences) + preferences.deregister_plugin_preferences(self) + + @on_plugin_teardown(plugin=Plugins.Console) + def on_console_teardown(self): + console = self.get_plugin(Plugins.Console) + console.sig_refreshed.disconnect(self.refresh) + + @on_plugin_teardown(plugin=Plugins.IPythonConsole) + def on_ipyconsole_teardown(self): + ipyconsole = self.get_plugin(Plugins.IPythonConsole) + ipyconsole.sig_append_to_history_requested.disconnect( + self.append_to_history) + + def update_font(self): + color_scheme = self.get_color_scheme() + font = self.get_font() + self.get_widget().update_font(font, color_scheme) + + # --- Plubic API + # ------------------------------------------------------------------------ + def refresh(self): + """ + Refresh main widget. + """ + self.get_widget().refresh() + + def add_history(self, filename): + """ + Create history file. + + Parameters + ---------- + filename: str + History file. + """ + self.get_widget().add_history(filename) + + def append_to_history(self, filename, command): + """ + Append command to history file. + + Parameters + ---------- + filename: str + History file. + command: str + Command to append to history file. + """ + self.get_widget().append_to_history(filename, command) diff --git a/spyder/plugins/io_dcm/plugin.py b/spyder/plugins/io_dcm/plugin.py index d32d6697edf..5b5edc84694 100644 --- a/spyder/plugins/io_dcm/plugin.py +++ b/spyder/plugins/io_dcm/plugin.py @@ -1,36 +1,36 @@ -# -*- coding: utf-8 -*- -# ----------------------------------------------------------------------------- -# Copyright (c) 2009- Spyder Project Contributors -# -# Distributed under the terms of the MIT License -# (see spyder/__init__.py for details) -# ----------------------------------------------------------------------------- - - -"""Example of I/O plugin for loading DICOM files.""" - - -# Standard library imports -import os.path as osp - - -try: - try: - # pydicom 0.9 - import dicom as dicomio - except ImportError: - # pydicom 1.0 - from pydicom import dicomio - def load_dicom(filename): - try: - name = osp.splitext(osp.basename(filename))[0] - try: - data = dicomio.read_file(filename, force=True) - except TypeError: - data = dicomio.read_file(filename) - arr = data.pixel_array - return {name: arr}, None - except Exception as error: - return None, str(error) -except ImportError: - load_dicom = None +# -*- coding: utf-8 -*- +# ----------------------------------------------------------------------------- +# Copyright (c) 2009- Spyder Project Contributors +# +# Distributed under the terms of the MIT License +# (see spyder/__init__.py for details) +# ----------------------------------------------------------------------------- + + +"""Example of I/O plugin for loading DICOM files.""" + + +# Standard library imports +import os.path as osp + + +try: + try: + # pydicom 0.9 + import dicom as dicomio + except ImportError: + # pydicom 1.0 + from pydicom import dicomio + def load_dicom(filename): + try: + name = osp.splitext(osp.basename(filename))[0] + try: + data = dicomio.read_file(filename, force=True) + except TypeError: + data = dicomio.read_file(filename) + arr = data.pixel_array + return {name: arr}, None + except Exception as error: + return None, str(error) +except ImportError: + load_dicom = None diff --git a/spyder/plugins/io_hdf5/plugin.py b/spyder/plugins/io_hdf5/plugin.py index 186f15c2d20..228e154156a 100644 --- a/spyder/plugins/io_hdf5/plugin.py +++ b/spyder/plugins/io_hdf5/plugin.py @@ -1,82 +1,82 @@ -# -*- coding: utf-8 -*- -# ----------------------------------------------------------------------------- -# Copyright (c) 2009- Spyder Project Contributors -# -# Distributed under the terms of the MIT License -# (see spyder/__init__.py for details) -# ----------------------------------------------------------------------------- - - -""" -I/O plugin for loading/saving HDF5 files. - -Note that this is a fairly dumb implementation which reads the whole HDF5 file into -Spyder's variable explorer. Since HDF5 files are designed for storing very large -data-sets, it may be much better to work directly with the HDF5 objects, thus keeping -the data on disk. Nonetheless, this plugin gives quick and dirty but convenient -access to HDF5 files. - -There is no support for creating files with compression, chunking etc, although -these can be read without problem. - -All datatypes to be saved must be convertible to a numpy array, otherwise an exception -will be raised. - -Data attributes are currently ignored. - -When reading an HDF5 file with sub-groups, groups in the HDF5 file will -correspond to dictionaries with the same layout. However, when saving -data, dictionaries are not turned into HDF5 groups. - -TODO: Look for the pytables library if h5py is not found?? -TODO: Check issues with valid python names vs valid h5f5 names -""" - -from __future__ import print_function - -import importlib -# Do not import h5py here because it will try to import IPython, -# and this is freezing the Spyder GUI - -import numpy as np - -if importlib.util.find_spec('h5py'): - def load_hdf5(filename): - import h5py - def get_group(group): - contents = {} - for name, obj in list(group.items()): - if isinstance(obj, h5py.Dataset): - contents[name] = np.array(obj) - elif isinstance(obj, h5py.Group): - # it is a group, so call self recursively - contents[name] = get_group(obj) - # other objects such as links are ignored - return contents - - try: - f = h5py.File(filename, 'r') - contents = get_group(f) - f.close() - return contents, None - except Exception as error: - return None, str(error) - - def save_hdf5(data, filename): - import h5py - try: - f = h5py.File(filename, 'w') - for key, value in list(data.items()): - f[key] = np.array(value) - f.close() - except Exception as error: - return str(error) -else: - load_hdf5 = None - save_hdf5 = None - - -if __name__ == "__main__": - data = {'a' : [1, 2, 3, 4], 'b' : 4.5} - print(save_hdf5(data, "test.h5")) # spyder: test-skip - print(load_hdf5("test.h5")) # spyder: test-skip +# -*- coding: utf-8 -*- +# ----------------------------------------------------------------------------- +# Copyright (c) 2009- Spyder Project Contributors +# +# Distributed under the terms of the MIT License +# (see spyder/__init__.py for details) +# ----------------------------------------------------------------------------- + + +""" +I/O plugin for loading/saving HDF5 files. + +Note that this is a fairly dumb implementation which reads the whole HDF5 file into +Spyder's variable explorer. Since HDF5 files are designed for storing very large +data-sets, it may be much better to work directly with the HDF5 objects, thus keeping +the data on disk. Nonetheless, this plugin gives quick and dirty but convenient +access to HDF5 files. + +There is no support for creating files with compression, chunking etc, although +these can be read without problem. + +All datatypes to be saved must be convertible to a numpy array, otherwise an exception +will be raised. + +Data attributes are currently ignored. + +When reading an HDF5 file with sub-groups, groups in the HDF5 file will +correspond to dictionaries with the same layout. However, when saving +data, dictionaries are not turned into HDF5 groups. + +TODO: Look for the pytables library if h5py is not found?? +TODO: Check issues with valid python names vs valid h5f5 names +""" + +from __future__ import print_function + +import importlib +# Do not import h5py here because it will try to import IPython, +# and this is freezing the Spyder GUI + +import numpy as np + +if importlib.util.find_spec('h5py'): + def load_hdf5(filename): + import h5py + def get_group(group): + contents = {} + for name, obj in list(group.items()): + if isinstance(obj, h5py.Dataset): + contents[name] = np.array(obj) + elif isinstance(obj, h5py.Group): + # it is a group, so call self recursively + contents[name] = get_group(obj) + # other objects such as links are ignored + return contents + + try: + f = h5py.File(filename, 'r') + contents = get_group(f) + f.close() + return contents, None + except Exception as error: + return None, str(error) + + def save_hdf5(data, filename): + import h5py + try: + f = h5py.File(filename, 'w') + for key, value in list(data.items()): + f[key] = np.array(value) + f.close() + except Exception as error: + return str(error) +else: + load_hdf5 = None + save_hdf5 = None + + +if __name__ == "__main__": + data = {'a' : [1, 2, 3, 4], 'b' : 4.5} + print(save_hdf5(data, "test.h5")) # spyder: test-skip + print(load_hdf5("test.h5")) # spyder: test-skip diff --git a/spyder/plugins/ipythonconsole/plugin.py b/spyder/plugins/ipythonconsole/plugin.py index 37e91ee0f7d..17df1a897a5 100644 --- a/spyder/plugins/ipythonconsole/plugin.py +++ b/spyder/plugins/ipythonconsole/plugin.py @@ -1,887 +1,887 @@ -# -*- coding: utf-8 -*- -# -# Copyright © Spyder Project Contributors -# Licensed under the terms of the MIT License -# (see spyder/__init__.py for details) - -""" -IPython Console plugin based on QtConsole. -""" - -# Standard library imports -import os -import os.path as osp - -# Third party imports -from qtpy.QtCore import Signal, Slot - -# Local imports -from spyder.api.plugins import Plugins, SpyderDockablePlugin -from spyder.api.plugin_registration.decorators import ( - on_plugin_available, on_plugin_teardown) -from spyder.api.translations import get_translation -from spyder.plugins.ipythonconsole.confpage import IPythonConsoleConfigPage -from spyder.plugins.ipythonconsole.widgets.main_widget import ( - IPythonConsoleWidget, IPythonConsoleWidgetOptionsMenus) -from spyder.plugins.mainmenu.api import ( - ApplicationMenus, ConsolesMenuSections, HelpMenuSections) -from spyder.utils.programs import get_temp_dir - -# Localization -_ = get_translation('spyder') - - -class IPythonConsole(SpyderDockablePlugin): - """ - IPython Console plugin - - This is a widget with tabs where each one is a ClientWidget - """ - - # This is required for the new API - NAME = 'ipython_console' - REQUIRES = [Plugins.Console, Plugins.Preferences] - OPTIONAL = [Plugins.Editor, Plugins.History, Plugins.MainMenu, - Plugins.Projects, Plugins.WorkingDirectory] - TABIFY = [Plugins.History] - WIDGET_CLASS = IPythonConsoleWidget - CONF_SECTION = NAME - CONF_WIDGET_CLASS = IPythonConsoleConfigPage - CONF_FILE = False - DISABLE_ACTIONS_WHEN_HIDDEN = False - RAISE_AND_FOCUS = True - - # Signals - sig_append_to_history_requested = Signal(str, str) - """ - This signal is emitted when the plugin requires to add commands to a - history file. - - Parameters - ---------- - filename: str - History file filename. - text: str - Text to append to the history file. - """ - - sig_history_requested = Signal(str) - """ - This signal is emitted when the plugin wants a specific history file - to be shown. - - Parameters - ---------- - path: str - Path to history file. - """ - - sig_focus_changed = Signal() - """ - This signal is emitted when the plugin focus changes. - """ - - sig_edit_goto_requested = Signal((str, int, str), (str, int, str, bool)) - """ - This signal will request to open a file in a given row and column - using a code editor. - - Parameters - ---------- - path: str - Path to file. - row: int - Cursor starting row position. - word: str - Word to select on given row. - processevents: bool - True if the code editor need to process qt events when loading the - requested file. - """ - - sig_edit_new = Signal(str) - """ - This signal will request to create a new file in a code editor. - - Parameters - ---------- - path: str - Path to file. - """ - - sig_pdb_state_changed = Signal(bool, dict) - """ - This signal is emitted when the debugging state changes. - - Parameters - ---------- - waiting_pdb_input: bool - If the debugging session is waiting for input. - pdb_last_step: dict - Dictionary with the information of the last step done - in the debugging session. - """ - - sig_shellwidget_created = Signal(object) - """ - This signal is emitted when a shellwidget is connected to - a kernel. - - Parameters - ---------- - shellwidget: spyder.plugins.ipyconsole.widgets.shell.ShellWidget - The shellwigdet. - """ - - sig_shellwidget_deleted = Signal(object) - """ - This signal is emitted when a shellwidget is disconnected from - a kernel. - - Parameters - ---------- - shellwidget: spyder.plugins.ipyconsole.widgets.shell.ShellWidget - The shellwigdet. - """ - - sig_shellwidget_changed = Signal(object) - """ - This signal is emitted when the current shellwidget changes. - - Parameters - ---------- - shellwidget: spyder.plugins.ipyconsole.widgets.shell.ShellWidget - The shellwigdet. - """ - - sig_external_spyder_kernel_connected = Signal(object) - """ - This signal is emitted when we connect to an external Spyder kernel. - - Parameters - ---------- - shellwidget: spyder.plugins.ipyconsole.widgets.shell.ShellWidget - The shellwigdet that was connected to the kernel. - """ - - sig_render_plain_text_requested = Signal(str) - """ - This signal is emitted to request a plain text help render. - - Parameters - ---------- - plain_text: str - The plain text to render. - """ - - sig_render_rich_text_requested = Signal(str, bool) - """ - This signal is emitted to request a rich text help render. - - Parameters - ---------- - rich_text: str - The rich text. - collapse: bool - If the text contains collapsed sections, show them closed (True) or - open (False). - """ - - sig_help_requested = Signal(dict) - """ - This signal is emitted to request help on a given object `name`. - - Parameters - ---------- - help_data: dict - Example `{'name': str, 'ignore_unknown': bool}`. - """ - - sig_current_directory_changed = Signal(str) - """ - This signal is emitted when the current directory of the active shell - widget has changed. - - Parameters - ---------- - working_directory: str - The new working directory path. - """ - - # ---- SpyderDockablePlugin API - # ------------------------------------------------------------------------- - @staticmethod - def get_name(): - return _('IPython console') - - def get_description(self): - return _('IPython console') - - def get_icon(self): - return self.create_icon('ipython_console') - - def on_initialize(self): - widget = self.get_widget() - widget.sig_append_to_history_requested.connect( - self.sig_append_to_history_requested) - widget.sig_focus_changed.connect(self.sig_focus_changed) - widget.sig_switch_to_plugin_requested.connect(self.switch_to_plugin) - widget.sig_history_requested.connect(self.sig_history_requested) - widget.sig_edit_goto_requested.connect(self.sig_edit_goto_requested) - widget.sig_edit_goto_requested[str, int, str, bool].connect( - self.sig_edit_goto_requested[str, int, str, bool]) - widget.sig_edit_new.connect(self.sig_edit_new) - widget.sig_pdb_state_changed.connect(self.sig_pdb_state_changed) - 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_external_spyder_kernel_connected.connect( - self.sig_external_spyder_kernel_connected) - widget.sig_render_plain_text_requested.connect( - self.sig_render_plain_text_requested) - widget.sig_render_rich_text_requested.connect( - self.sig_render_rich_text_requested) - widget.sig_help_requested.connect(self.sig_help_requested) - widget.sig_current_directory_changed.connect( - self.sig_current_directory_changed) - widget.sig_exception_occurred.connect(self.sig_exception_occurred) - - # Update kernels if python path is changed - self.main.sig_pythonpath_changed.connect(self.update_path) - - self.sig_focus_changed.connect(self.main.plugin_focus_changed) - self._remove_old_std_files() - - @on_plugin_available(plugin=Plugins.Preferences) - def on_preferences_available(self): - # Register conf page - preferences = self.get_plugin(Plugins.Preferences) - preferences.register_plugin_preferences(self) - - @on_plugin_available(plugin=Plugins.MainMenu) - def on_main_menu_available(self): - widget = self.get_widget() - mainmenu = self.get_plugin(Plugins.MainMenu) - - # Add signal to update actions state before showing the menu - console_menu = mainmenu.get_application_menu( - ApplicationMenus.Consoles) - console_menu.aboutToShow.connect( - widget.update_actions) - - # Main menu actions for the IPython Console - new_consoles_actions = [ - widget.create_client_action, - widget.special_console_menu, - widget.connect_to_kernel_action - ] - - restart_connect_consoles_actions = [ - widget.interrupt_action, - widget.restart_action, - widget.reset_action - ] - - # Console menu - for console_new_action in new_consoles_actions: - mainmenu.add_item_to_application_menu( - console_new_action, - menu_id=ApplicationMenus.Consoles, - section=ConsolesMenuSections.New, - ) - - for console_action in restart_connect_consoles_actions: - mainmenu.add_item_to_application_menu( - console_action, - menu_id=ApplicationMenus.Consoles, - section=ConsolesMenuSections.Restart, - ) - - # IPython documentation - mainmenu.add_item_to_application_menu( - self.get_widget().ipython_menu, - menu_id=ApplicationMenus.Help, - section=HelpMenuSections.ExternalDocumentation, - before_section=HelpMenuSections.About, - ) - - @on_plugin_available(plugin=Plugins.Editor) - def on_editor_available(self): - editor = self.get_plugin(Plugins.Editor) - self.sig_edit_goto_requested.connect(editor.load) - self.sig_edit_goto_requested[str, int, str, bool].connect( - self._load_file_in_editor) - self.sig_edit_new.connect(editor.new) - editor.breakpoints_saved.connect(self.set_spyder_breakpoints) - editor.run_in_current_ipyclient.connect(self.run_script) - editor.run_cell_in_ipyclient.connect(self.run_cell) - editor.debug_cell_in_ipyclient.connect(self.debug_cell) - - # Connect Editor debug action with Console - self.sig_pdb_state_changed.connect(editor.update_pdb_state) - editor.exec_in_extconsole.connect(self.execute_code_and_focus_editor) - editor.sig_file_debug_message_requested.connect( - self.print_debug_file_msg) - - @on_plugin_available(plugin=Plugins.Projects) - def on_projects_available(self): - projects = self.get_plugin(Plugins.Projects) - projects.sig_project_loaded.connect(self._on_project_loaded) - projects.sig_project_closed.connect(self._on_project_closed) - - @on_plugin_available(plugin=Plugins.WorkingDirectory) - def on_working_directory_available(self): - working_directory = self.get_plugin(Plugins.WorkingDirectory) - working_directory.sig_current_directory_changed.connect( - self._set_working_directory) - - @on_plugin_teardown(plugin=Plugins.Preferences) - def on_preferences_teardown(self): - # Register conf page - preferences = self.get_plugin(Plugins.Preferences) - preferences.deregister_plugin_preferences(self) - - @on_plugin_teardown(plugin=Plugins.MainMenu) - def on_main_menu_teardown(self): - mainmenu = self.get_plugin(Plugins.MainMenu) - mainmenu.remove_application_menu(ApplicationMenus.Consoles) - - # IPython documentation menu - mainmenu.remove_item_from_application_menu( - IPythonConsoleWidgetOptionsMenus.Documentation, - menu_id=ApplicationMenus.Help - ) - - @on_plugin_teardown(plugin=Plugins.Editor) - def on_editor_teardown(self): - editor = self.get_plugin(Plugins.Editor) - self.sig_edit_goto_requested.disconnect(editor.load) - self.sig_edit_goto_requested[str, int, str, bool].disconnect( - self._load_file_in_editor) - self.sig_edit_new.disconnect(editor.new) - editor.breakpoints_saved.disconnect(self.set_spyder_breakpoints) - editor.run_in_current_ipyclient.disconnect(self.run_script) - editor.run_cell_in_ipyclient.disconnect(self.run_cell) - editor.debug_cell_in_ipyclient.disconnect(self.debug_cell) - - # Connect Editor debug action with Console - self.sig_pdb_state_changed.disconnect(editor.update_pdb_state) - editor.exec_in_extconsole.disconnect( - self.execute_code_and_focus_editor) - editor.sig_file_debug_message_requested.disconnect( - self.print_debug_file_msg) - - @on_plugin_teardown(plugin=Plugins.Projects) - def on_projects_teardown(self): - projects = self.get_plugin(Plugins.Projects) - projects.sig_project_loaded.disconnect(self._on_project_loaded) - projects.sig_project_closed.disconnect(self._on_project_closed) - - @on_plugin_teardown(plugin=Plugins.WorkingDirectory) - def on_working_directory_teardown(self): - working_directory = self.get_plugin(Plugins.WorkingDirectory) - working_directory.sig_current_directory_changed.disconnect( - self._set_working_directory) - - def update_font(self): - """Update font from Preferences""" - font = self.get_font() - rich_font = self.get_font(rich_text=True) - self.get_widget().update_font(font, rich_font) - - def on_close(self, cancelable=False): - """Perform actions when plugin is closed""" - self.get_widget().mainwindow_close = True - return self.get_widget().close_clients() - - def on_mainwindow_visible(self): - self.create_new_client(give_focus=False) - - # Raise plugin the first time Spyder starts - if self.get_conf('show_first_time', default=True): - self.dockwidget.raise_() - self.set_conf('show_first_time', False) - - # ---- Private methods - # ------------------------------------------------------------------------- - def _load_file_in_editor(self, fname, lineno, word, processevents): - editor = self.get_plugin(Plugins.Editor) - editor.load(fname, lineno, word, processevents=processevents) - - def _switch_to_editor(self): - editor = self.get_plugin(Plugins.Editor) - editor.switch_to_plugin() - - def _on_project_loaded(self): - projects = self.get_plugin(Plugins.Projects) - self.get_widget().update_active_project_path( - projects.get_active_project_path()) - - def _on_project_closed(self): - self.get_widget().update_active_project_path(None) - - def _remove_old_std_files(self): - """ - Remove std files left by previous Spyder instances. - - This is only required on Windows because we can't - clean up std files while Spyder is running on that - platform. - """ - if os.name == 'nt': - tmpdir = get_temp_dir() - for fname in os.listdir(tmpdir): - if osp.splitext(fname)[1] in ('.stderr', '.stdout', '.fault'): - try: - os.remove(osp.join(tmpdir, fname)) - except Exception: - pass - - @Slot(str) - def _set_working_directory(self, new_dir): - """Set current working directory on the main widget.""" - self.get_widget().set_working_directory(new_dir) - - # ---- Public API - # ------------------------------------------------------------------------- - - # ---- Spyder Kernels handlers registry functionality - def register_spyder_kernel_call_handler(self, handler_id, handler): - """ - Register a callback for it to be available for the kernels of new - clients. - - Parameters - ---------- - handler_id : str - Handler name to be registered and that will be used to - call the respective handler in the Spyder kernel. - handler : func - Callback function that will be called when the kernel calls - the handler. - - Returns - ------- - None. - """ - self.get_widget().register_spyder_kernel_call_handler( - handler_id, handler) - - def unregister_spyder_kernel_call_handler(self, handler_id): - """ - Unregister/remove a handler for not be added to new clients kernels - - Parameters - ---------- - handler_id : str - Handler name that was registered and that will be removed - from the Spyder kernel available handlers. - - Returns - ------- - None. - """ - self.get_widget().unregister_spyder_kernel_call_handler(handler_id) - - # ---- For client widgets - def get_clients(self): - """Return clients list""" - return self.get_widget().clients - - def get_focus_client(self): - """Return current client with focus, if any""" - return self.get_widget().get_focus_client() - - def get_current_client(self): - """Return the currently selected client""" - return self.get_widget().get_current_client() - - def get_current_shellwidget(self): - """Return the shellwidget of the current client""" - return self.get_widget().get_current_shellwidget() - - def rename_client_tab(self, client, given_name): - """ - Rename a client's tab. - - Parameters - ---------- - client: spyder.plugins.ipythonconsole.widgets.client.ClientWidget - Client to rename. - given_name: str - New name to be given to the client's tab. - - Returns - ------- - None. - """ - self.get_widget().rename_client_tab(client, given_name) - - def create_new_client(self, give_focus=True, filename='', is_cython=False, - is_pylab=False, is_sympy=False, given_name=None): - """ - Create a new client. - - Parameters - ---------- - give_focus : bool, optional - True if the new client should gain the window - focus, False otherwise. The default is True. - filename : str, optional - Filename associated with the client. The default is ''. - is_cython : bool, optional - True if the client is expected to preload Cython support, - False otherwise. The default is False. - is_pylab : bool, optional - True if the client is expected to preload PyLab support, - False otherwise. The default is False. - is_sympy : bool, optional - True if the client is expected to preload Sympy support, - False otherwise. The default is False. - given_name : str, optional - Initial name displayed in the tab of the client. - The default is None. - - Returns - ------- - None. - """ - self.get_widget().create_new_client( - give_focus=give_focus, - filename=filename, - is_cython=is_cython, - is_pylab=is_pylab, - is_sympy=is_sympy, - given_name=given_name) - - def create_client_for_file(self, filename, is_cython=False): - """ - Create a client widget to execute code related to a file. - - Parameters - ---------- - filename : str - File to be executed. - is_cython : bool, optional - If the execution is for a Cython file. The default is False. - - Returns - ------- - None. - """ - self.get_widget().create_client_for_file(filename, is_cython=is_cython) - - def create_client_for_kernel(self, connection_file, hostname=None, - sshkey=None, password=None): - """ - Create a client connected to an existing kernel. - - Parameters - ---------- - connection_file: str - Json file that has the kernel's connection info. - hostname: str, optional - Name or IP address of the remote machine where the kernel was - started. When this is provided, it's also necessary to pass either - the ``sshkey`` or ``password`` arguments. - sshkey: str, optional - SSH key file to connect to the remote machine where the kernel is - running. - password: str, optional - Password to authenticate to the remote machine where the kernel is - running. - - Returns - ------- - None. - """ - self.get_widget().create_client_for_kernel( - connection_file, hostname, sshkey, password) - - def get_client_for_file(self, filename): - """Get client associated with a given file name.""" - return self.get_widget().get_client_for_file(filename) - - def create_client_from_path(self, path): - """ - Create a new console with `path` set as the current working directory. - - Parameters - ---------- - path: str - Path to use as working directory in new console. - """ - self.get_widget().create_client_from_path(path) - - def close_client(self, index=None, client=None, ask_recursive=True): - """Close client tab from index or client (or close current tab)""" - self.get_widget().close_client(index=index, client=client, - ask_recursive=ask_recursive) - - # ---- For execution and debugging - def run_script(self, filename, wdir, args, debug, post_mortem, - current_client, clear_variables, console_namespace): - """ - Run script in current or dedicated client. - - Parameters - ---------- - filename : str - Path to file that will be run. - wdir : str - Working directory from where the file should be run. - args : str - Arguments defined to run the file. - debug : bool - True if the run if for debugging the file, - False for just running it. - post_mortem : bool - True if in case of error the execution should enter in - post-mortem mode, False otherwise. - current_client : bool - True if the execution should be done in the current client, - False if the execution needs to be done in a dedicated client. - clear_variables : bool - True if all the variables should be removed before execution, - False otherwise. - console_namespace : bool - True if the console namespace should be used, False otherwise. - - Returns - ------- - None. - """ - self.get_widget().run_script( - filename, - wdir, - args, - debug, - post_mortem, - current_client, - clear_variables, - console_namespace) - - def run_cell(self, code, cell_name, filename, run_cell_copy, - focus_to_editor, function='runcell'): - """ - Run cell in current or dedicated client. - - Parameters - ---------- - code : str - Piece of code to run that corresponds to a cell in case - `run_cell_copy` is True. - cell_name : str or int - Cell name or index. - filename : str - Path of the file where the cell to execute is located. - run_cell_copy : bool - True if the cell should be executed line by line, - False if the provided `function` should be used. - focus_to_editor: bool - Whether to give focus to the editor after running the cell. If - False, focus is given to the console. - function : str, optional - Name handler of the kernel function to be used to execute the cell - in case `run_cell_copy` is False. - The default is 'runcell'. - - Returns - ------- - None. - """ - self.get_widget().run_cell( - code, cell_name, filename, run_cell_copy, focus_to_editor, - function=function) - - def debug_cell(self, code, cell_name, filename, run_cell_copy, - focus_to_editor): - """ - Debug current cell. - - Parameters - ---------- - code : str - Piece of code to run that corresponds to a cell in case - `run_cell_copy` is True. - cell_name : str or int - Cell name or index. - filename : str - Path of the file where the cell to execute is located. - run_cell_copy : bool - True if the cell should be executed line by line, - False if the `debugcell` kernel function should be used. - focus_to_editor: bool - Whether to give focus to the editor after debugging the cell. If - False, focus is given to the console. - - Returns - ------- - None. - """ - self.get_widget().debug_cell(code, cell_name, filename, run_cell_copy, - focus_to_editor) - - def execute_code(self, lines, current_client=True, clear_variables=False): - """ - Execute code instructions. - - Parameters - ---------- - lines : str - Code lines to execute. - current_client : bool, optional - True if the execution should be done in the current client. - The default is True. - clear_variables : bool, optional - True if before the execution the variables should be cleared. - The default is False. - - Returns - ------- - None. - """ - self.get_widget().execute_code( - lines, - current_client=current_client, - clear_variables=clear_variables) - - def execute_code_and_focus_editor(self, lines, focus_to_editor=True): - """ - Execute lines in IPython console and eventually set focus - to the Editor. - """ - self.execute_code(lines) - if focus_to_editor and self.get_plugin(Plugins.Editor): - self._switch_to_editor() - else: - self.switch_to_plugin() - - def stop_debugging(self): - """Stop debugging in the current console.""" - self.get_widget().stop_debugging() - - def get_pdb_state(self): - """Get debugging state of the current console.""" - return self.get_widget().get_pdb_state() - - def get_pdb_last_step(self): - """Get last pdb step of the current console.""" - return self.get_widget().get_pdb_last_step() - - def pdb_execute_command(self, command): - """ - Send command to the pdb kernel if possible. - - Parameters - ---------- - command : str - Command to execute by the pdb kernel. - - Returns - ------- - None. - """ - self.get_widget().pdb_execute_command(command) - - def print_debug_file_msg(self): - """ - Print message in the current console when a file can't be closed. - - Returns - ------- - None. - """ - self.get_widget().print_debug_file_msg() - - # ---- For working directory and path management - def set_current_client_working_directory(self, directory): - """ - Set current client working directory. - - Parameters - ---------- - directory : str - Path for the new current working directory. - - Returns - ------- - None. - """ - self.get_widget().set_current_client_working_directory(directory) - - def set_working_directory(self, dirname): - """ - Set current working directory for the `workingdirectory` and `explorer` - plugins. - - Parameters - ---------- - dirname : str - Path to the new current working directory. - - Returns - ------- - None. - """ - self.get_widget().set_working_directory(dirname) - - def update_working_directory(self): - """Update working directory to console current working directory.""" - self.get_widget().update_working_directory() - - def update_path(self, path_dict, new_path_dict): - """ - Update path on consoles. - - Both parameters have as keys paths and as value if the path - should be used/is active (True) or not (False) - - Parameters - ---------- - path_dict : dict - Corresponds to the previous state of the PYTHONPATH. - new_path_dict : dict - Corresponds to the new state of the PYTHONPATH. - - Returns - ------- - None. - """ - self.get_widget().update_path(path_dict, new_path_dict) - - def set_spyder_breakpoints(self): - """Set Spyder breakpoints into all clients""" - self.get_widget().set_spyder_breakpoints() - - def restart(self): - """ - Restart the console. - - This is needed when we switch projects to update PYTHONPATH - and the selected interpreter. - """ - self.get_widget().restart() - - def restart_kernel(self): - """ - Restart the current client's kernel. - - Returns - ------- - None. - """ - self.get_widget().restart_kernel() - - # ---- For documentation and help - def show_intro(self): - """Show intro to IPython help.""" - self.get_widget().show_intro() - - def show_guiref(self): - """Show qtconsole help.""" - self.get_widget().show_guiref() - - def show_quickref(self): - """Show IPython Cheat Sheet.""" - self.get_widget().show_quickref() +# -*- coding: utf-8 -*- +# +# Copyright © Spyder Project Contributors +# Licensed under the terms of the MIT License +# (see spyder/__init__.py for details) + +""" +IPython Console plugin based on QtConsole. +""" + +# Standard library imports +import os +import os.path as osp + +# Third party imports +from qtpy.QtCore import Signal, Slot + +# Local imports +from spyder.api.plugins import Plugins, SpyderDockablePlugin +from spyder.api.plugin_registration.decorators import ( + on_plugin_available, on_plugin_teardown) +from spyder.api.translations import get_translation +from spyder.plugins.ipythonconsole.confpage import IPythonConsoleConfigPage +from spyder.plugins.ipythonconsole.widgets.main_widget import ( + IPythonConsoleWidget, IPythonConsoleWidgetOptionsMenus) +from spyder.plugins.mainmenu.api import ( + ApplicationMenus, ConsolesMenuSections, HelpMenuSections) +from spyder.utils.programs import get_temp_dir + +# Localization +_ = get_translation('spyder') + + +class IPythonConsole(SpyderDockablePlugin): + """ + IPython Console plugin + + This is a widget with tabs where each one is a ClientWidget + """ + + # This is required for the new API + NAME = 'ipython_console' + REQUIRES = [Plugins.Console, Plugins.Preferences] + OPTIONAL = [Plugins.Editor, Plugins.History, Plugins.MainMenu, + Plugins.Projects, Plugins.WorkingDirectory] + TABIFY = [Plugins.History] + WIDGET_CLASS = IPythonConsoleWidget + CONF_SECTION = NAME + CONF_WIDGET_CLASS = IPythonConsoleConfigPage + CONF_FILE = False + DISABLE_ACTIONS_WHEN_HIDDEN = False + RAISE_AND_FOCUS = True + + # Signals + sig_append_to_history_requested = Signal(str, str) + """ + This signal is emitted when the plugin requires to add commands to a + history file. + + Parameters + ---------- + filename: str + History file filename. + text: str + Text to append to the history file. + """ + + sig_history_requested = Signal(str) + """ + This signal is emitted when the plugin wants a specific history file + to be shown. + + Parameters + ---------- + path: str + Path to history file. + """ + + sig_focus_changed = Signal() + """ + This signal is emitted when the plugin focus changes. + """ + + sig_edit_goto_requested = Signal((str, int, str), (str, int, str, bool)) + """ + This signal will request to open a file in a given row and column + using a code editor. + + Parameters + ---------- + path: str + Path to file. + row: int + Cursor starting row position. + word: str + Word to select on given row. + processevents: bool + True if the code editor need to process qt events when loading the + requested file. + """ + + sig_edit_new = Signal(str) + """ + This signal will request to create a new file in a code editor. + + Parameters + ---------- + path: str + Path to file. + """ + + sig_pdb_state_changed = Signal(bool, dict) + """ + This signal is emitted when the debugging state changes. + + Parameters + ---------- + waiting_pdb_input: bool + If the debugging session is waiting for input. + pdb_last_step: dict + Dictionary with the information of the last step done + in the debugging session. + """ + + sig_shellwidget_created = Signal(object) + """ + This signal is emitted when a shellwidget is connected to + a kernel. + + Parameters + ---------- + shellwidget: spyder.plugins.ipyconsole.widgets.shell.ShellWidget + The shellwigdet. + """ + + sig_shellwidget_deleted = Signal(object) + """ + This signal is emitted when a shellwidget is disconnected from + a kernel. + + Parameters + ---------- + shellwidget: spyder.plugins.ipyconsole.widgets.shell.ShellWidget + The shellwigdet. + """ + + sig_shellwidget_changed = Signal(object) + """ + This signal is emitted when the current shellwidget changes. + + Parameters + ---------- + shellwidget: spyder.plugins.ipyconsole.widgets.shell.ShellWidget + The shellwigdet. + """ + + sig_external_spyder_kernel_connected = Signal(object) + """ + This signal is emitted when we connect to an external Spyder kernel. + + Parameters + ---------- + shellwidget: spyder.plugins.ipyconsole.widgets.shell.ShellWidget + The shellwigdet that was connected to the kernel. + """ + + sig_render_plain_text_requested = Signal(str) + """ + This signal is emitted to request a plain text help render. + + Parameters + ---------- + plain_text: str + The plain text to render. + """ + + sig_render_rich_text_requested = Signal(str, bool) + """ + This signal is emitted to request a rich text help render. + + Parameters + ---------- + rich_text: str + The rich text. + collapse: bool + If the text contains collapsed sections, show them closed (True) or + open (False). + """ + + sig_help_requested = Signal(dict) + """ + This signal is emitted to request help on a given object `name`. + + Parameters + ---------- + help_data: dict + Example `{'name': str, 'ignore_unknown': bool}`. + """ + + sig_current_directory_changed = Signal(str) + """ + This signal is emitted when the current directory of the active shell + widget has changed. + + Parameters + ---------- + working_directory: str + The new working directory path. + """ + + # ---- SpyderDockablePlugin API + # ------------------------------------------------------------------------- + @staticmethod + def get_name(): + return _('IPython console') + + def get_description(self): + return _('IPython console') + + def get_icon(self): + return self.create_icon('ipython_console') + + def on_initialize(self): + widget = self.get_widget() + widget.sig_append_to_history_requested.connect( + self.sig_append_to_history_requested) + widget.sig_focus_changed.connect(self.sig_focus_changed) + widget.sig_switch_to_plugin_requested.connect(self.switch_to_plugin) + widget.sig_history_requested.connect(self.sig_history_requested) + widget.sig_edit_goto_requested.connect(self.sig_edit_goto_requested) + widget.sig_edit_goto_requested[str, int, str, bool].connect( + self.sig_edit_goto_requested[str, int, str, bool]) + widget.sig_edit_new.connect(self.sig_edit_new) + widget.sig_pdb_state_changed.connect(self.sig_pdb_state_changed) + 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_external_spyder_kernel_connected.connect( + self.sig_external_spyder_kernel_connected) + widget.sig_render_plain_text_requested.connect( + self.sig_render_plain_text_requested) + widget.sig_render_rich_text_requested.connect( + self.sig_render_rich_text_requested) + widget.sig_help_requested.connect(self.sig_help_requested) + widget.sig_current_directory_changed.connect( + self.sig_current_directory_changed) + widget.sig_exception_occurred.connect(self.sig_exception_occurred) + + # Update kernels if python path is changed + self.main.sig_pythonpath_changed.connect(self.update_path) + + self.sig_focus_changed.connect(self.main.plugin_focus_changed) + self._remove_old_std_files() + + @on_plugin_available(plugin=Plugins.Preferences) + def on_preferences_available(self): + # Register conf page + preferences = self.get_plugin(Plugins.Preferences) + preferences.register_plugin_preferences(self) + + @on_plugin_available(plugin=Plugins.MainMenu) + def on_main_menu_available(self): + widget = self.get_widget() + mainmenu = self.get_plugin(Plugins.MainMenu) + + # Add signal to update actions state before showing the menu + console_menu = mainmenu.get_application_menu( + ApplicationMenus.Consoles) + console_menu.aboutToShow.connect( + widget.update_actions) + + # Main menu actions for the IPython Console + new_consoles_actions = [ + widget.create_client_action, + widget.special_console_menu, + widget.connect_to_kernel_action + ] + + restart_connect_consoles_actions = [ + widget.interrupt_action, + widget.restart_action, + widget.reset_action + ] + + # Console menu + for console_new_action in new_consoles_actions: + mainmenu.add_item_to_application_menu( + console_new_action, + menu_id=ApplicationMenus.Consoles, + section=ConsolesMenuSections.New, + ) + + for console_action in restart_connect_consoles_actions: + mainmenu.add_item_to_application_menu( + console_action, + menu_id=ApplicationMenus.Consoles, + section=ConsolesMenuSections.Restart, + ) + + # IPython documentation + mainmenu.add_item_to_application_menu( + self.get_widget().ipython_menu, + menu_id=ApplicationMenus.Help, + section=HelpMenuSections.ExternalDocumentation, + before_section=HelpMenuSections.About, + ) + + @on_plugin_available(plugin=Plugins.Editor) + def on_editor_available(self): + editor = self.get_plugin(Plugins.Editor) + self.sig_edit_goto_requested.connect(editor.load) + self.sig_edit_goto_requested[str, int, str, bool].connect( + self._load_file_in_editor) + self.sig_edit_new.connect(editor.new) + editor.breakpoints_saved.connect(self.set_spyder_breakpoints) + editor.run_in_current_ipyclient.connect(self.run_script) + editor.run_cell_in_ipyclient.connect(self.run_cell) + editor.debug_cell_in_ipyclient.connect(self.debug_cell) + + # Connect Editor debug action with Console + self.sig_pdb_state_changed.connect(editor.update_pdb_state) + editor.exec_in_extconsole.connect(self.execute_code_and_focus_editor) + editor.sig_file_debug_message_requested.connect( + self.print_debug_file_msg) + + @on_plugin_available(plugin=Plugins.Projects) + def on_projects_available(self): + projects = self.get_plugin(Plugins.Projects) + projects.sig_project_loaded.connect(self._on_project_loaded) + projects.sig_project_closed.connect(self._on_project_closed) + + @on_plugin_available(plugin=Plugins.WorkingDirectory) + def on_working_directory_available(self): + working_directory = self.get_plugin(Plugins.WorkingDirectory) + working_directory.sig_current_directory_changed.connect( + self._set_working_directory) + + @on_plugin_teardown(plugin=Plugins.Preferences) + def on_preferences_teardown(self): + # Register conf page + preferences = self.get_plugin(Plugins.Preferences) + preferences.deregister_plugin_preferences(self) + + @on_plugin_teardown(plugin=Plugins.MainMenu) + def on_main_menu_teardown(self): + mainmenu = self.get_plugin(Plugins.MainMenu) + mainmenu.remove_application_menu(ApplicationMenus.Consoles) + + # IPython documentation menu + mainmenu.remove_item_from_application_menu( + IPythonConsoleWidgetOptionsMenus.Documentation, + menu_id=ApplicationMenus.Help + ) + + @on_plugin_teardown(plugin=Plugins.Editor) + def on_editor_teardown(self): + editor = self.get_plugin(Plugins.Editor) + self.sig_edit_goto_requested.disconnect(editor.load) + self.sig_edit_goto_requested[str, int, str, bool].disconnect( + self._load_file_in_editor) + self.sig_edit_new.disconnect(editor.new) + editor.breakpoints_saved.disconnect(self.set_spyder_breakpoints) + editor.run_in_current_ipyclient.disconnect(self.run_script) + editor.run_cell_in_ipyclient.disconnect(self.run_cell) + editor.debug_cell_in_ipyclient.disconnect(self.debug_cell) + + # Connect Editor debug action with Console + self.sig_pdb_state_changed.disconnect(editor.update_pdb_state) + editor.exec_in_extconsole.disconnect( + self.execute_code_and_focus_editor) + editor.sig_file_debug_message_requested.disconnect( + self.print_debug_file_msg) + + @on_plugin_teardown(plugin=Plugins.Projects) + def on_projects_teardown(self): + projects = self.get_plugin(Plugins.Projects) + projects.sig_project_loaded.disconnect(self._on_project_loaded) + projects.sig_project_closed.disconnect(self._on_project_closed) + + @on_plugin_teardown(plugin=Plugins.WorkingDirectory) + def on_working_directory_teardown(self): + working_directory = self.get_plugin(Plugins.WorkingDirectory) + working_directory.sig_current_directory_changed.disconnect( + self._set_working_directory) + + def update_font(self): + """Update font from Preferences""" + font = self.get_font() + rich_font = self.get_font(rich_text=True) + self.get_widget().update_font(font, rich_font) + + def on_close(self, cancelable=False): + """Perform actions when plugin is closed""" + self.get_widget().mainwindow_close = True + return self.get_widget().close_clients() + + def on_mainwindow_visible(self): + self.create_new_client(give_focus=False) + + # Raise plugin the first time Spyder starts + if self.get_conf('show_first_time', default=True): + self.dockwidget.raise_() + self.set_conf('show_first_time', False) + + # ---- Private methods + # ------------------------------------------------------------------------- + def _load_file_in_editor(self, fname, lineno, word, processevents): + editor = self.get_plugin(Plugins.Editor) + editor.load(fname, lineno, word, processevents=processevents) + + def _switch_to_editor(self): + editor = self.get_plugin(Plugins.Editor) + editor.switch_to_plugin() + + def _on_project_loaded(self): + projects = self.get_plugin(Plugins.Projects) + self.get_widget().update_active_project_path( + projects.get_active_project_path()) + + def _on_project_closed(self): + self.get_widget().update_active_project_path(None) + + def _remove_old_std_files(self): + """ + Remove std files left by previous Spyder instances. + + This is only required on Windows because we can't + clean up std files while Spyder is running on that + platform. + """ + if os.name == 'nt': + tmpdir = get_temp_dir() + for fname in os.listdir(tmpdir): + if osp.splitext(fname)[1] in ('.stderr', '.stdout', '.fault'): + try: + os.remove(osp.join(tmpdir, fname)) + except Exception: + pass + + @Slot(str) + def _set_working_directory(self, new_dir): + """Set current working directory on the main widget.""" + self.get_widget().set_working_directory(new_dir) + + # ---- Public API + # ------------------------------------------------------------------------- + + # ---- Spyder Kernels handlers registry functionality + def register_spyder_kernel_call_handler(self, handler_id, handler): + """ + Register a callback for it to be available for the kernels of new + clients. + + Parameters + ---------- + handler_id : str + Handler name to be registered and that will be used to + call the respective handler in the Spyder kernel. + handler : func + Callback function that will be called when the kernel calls + the handler. + + Returns + ------- + None. + """ + self.get_widget().register_spyder_kernel_call_handler( + handler_id, handler) + + def unregister_spyder_kernel_call_handler(self, handler_id): + """ + Unregister/remove a handler for not be added to new clients kernels + + Parameters + ---------- + handler_id : str + Handler name that was registered and that will be removed + from the Spyder kernel available handlers. + + Returns + ------- + None. + """ + self.get_widget().unregister_spyder_kernel_call_handler(handler_id) + + # ---- For client widgets + def get_clients(self): + """Return clients list""" + return self.get_widget().clients + + def get_focus_client(self): + """Return current client with focus, if any""" + return self.get_widget().get_focus_client() + + def get_current_client(self): + """Return the currently selected client""" + return self.get_widget().get_current_client() + + def get_current_shellwidget(self): + """Return the shellwidget of the current client""" + return self.get_widget().get_current_shellwidget() + + def rename_client_tab(self, client, given_name): + """ + Rename a client's tab. + + Parameters + ---------- + client: spyder.plugins.ipythonconsole.widgets.client.ClientWidget + Client to rename. + given_name: str + New name to be given to the client's tab. + + Returns + ------- + None. + """ + self.get_widget().rename_client_tab(client, given_name) + + def create_new_client(self, give_focus=True, filename='', is_cython=False, + is_pylab=False, is_sympy=False, given_name=None): + """ + Create a new client. + + Parameters + ---------- + give_focus : bool, optional + True if the new client should gain the window + focus, False otherwise. The default is True. + filename : str, optional + Filename associated with the client. The default is ''. + is_cython : bool, optional + True if the client is expected to preload Cython support, + False otherwise. The default is False. + is_pylab : bool, optional + True if the client is expected to preload PyLab support, + False otherwise. The default is False. + is_sympy : bool, optional + True if the client is expected to preload Sympy support, + False otherwise. The default is False. + given_name : str, optional + Initial name displayed in the tab of the client. + The default is None. + + Returns + ------- + None. + """ + self.get_widget().create_new_client( + give_focus=give_focus, + filename=filename, + is_cython=is_cython, + is_pylab=is_pylab, + is_sympy=is_sympy, + given_name=given_name) + + def create_client_for_file(self, filename, is_cython=False): + """ + Create a client widget to execute code related to a file. + + Parameters + ---------- + filename : str + File to be executed. + is_cython : bool, optional + If the execution is for a Cython file. The default is False. + + Returns + ------- + None. + """ + self.get_widget().create_client_for_file(filename, is_cython=is_cython) + + def create_client_for_kernel(self, connection_file, hostname=None, + sshkey=None, password=None): + """ + Create a client connected to an existing kernel. + + Parameters + ---------- + connection_file: str + Json file that has the kernel's connection info. + hostname: str, optional + Name or IP address of the remote machine where the kernel was + started. When this is provided, it's also necessary to pass either + the ``sshkey`` or ``password`` arguments. + sshkey: str, optional + SSH key file to connect to the remote machine where the kernel is + running. + password: str, optional + Password to authenticate to the remote machine where the kernel is + running. + + Returns + ------- + None. + """ + self.get_widget().create_client_for_kernel( + connection_file, hostname, sshkey, password) + + def get_client_for_file(self, filename): + """Get client associated with a given file name.""" + return self.get_widget().get_client_for_file(filename) + + def create_client_from_path(self, path): + """ + Create a new console with `path` set as the current working directory. + + Parameters + ---------- + path: str + Path to use as working directory in new console. + """ + self.get_widget().create_client_from_path(path) + + def close_client(self, index=None, client=None, ask_recursive=True): + """Close client tab from index or client (or close current tab)""" + self.get_widget().close_client(index=index, client=client, + ask_recursive=ask_recursive) + + # ---- For execution and debugging + def run_script(self, filename, wdir, args, debug, post_mortem, + current_client, clear_variables, console_namespace): + """ + Run script in current or dedicated client. + + Parameters + ---------- + filename : str + Path to file that will be run. + wdir : str + Working directory from where the file should be run. + args : str + Arguments defined to run the file. + debug : bool + True if the run if for debugging the file, + False for just running it. + post_mortem : bool + True if in case of error the execution should enter in + post-mortem mode, False otherwise. + current_client : bool + True if the execution should be done in the current client, + False if the execution needs to be done in a dedicated client. + clear_variables : bool + True if all the variables should be removed before execution, + False otherwise. + console_namespace : bool + True if the console namespace should be used, False otherwise. + + Returns + ------- + None. + """ + self.get_widget().run_script( + filename, + wdir, + args, + debug, + post_mortem, + current_client, + clear_variables, + console_namespace) + + def run_cell(self, code, cell_name, filename, run_cell_copy, + focus_to_editor, function='runcell'): + """ + Run cell in current or dedicated client. + + Parameters + ---------- + code : str + Piece of code to run that corresponds to a cell in case + `run_cell_copy` is True. + cell_name : str or int + Cell name or index. + filename : str + Path of the file where the cell to execute is located. + run_cell_copy : bool + True if the cell should be executed line by line, + False if the provided `function` should be used. + focus_to_editor: bool + Whether to give focus to the editor after running the cell. If + False, focus is given to the console. + function : str, optional + Name handler of the kernel function to be used to execute the cell + in case `run_cell_copy` is False. + The default is 'runcell'. + + Returns + ------- + None. + """ + self.get_widget().run_cell( + code, cell_name, filename, run_cell_copy, focus_to_editor, + function=function) + + def debug_cell(self, code, cell_name, filename, run_cell_copy, + focus_to_editor): + """ + Debug current cell. + + Parameters + ---------- + code : str + Piece of code to run that corresponds to a cell in case + `run_cell_copy` is True. + cell_name : str or int + Cell name or index. + filename : str + Path of the file where the cell to execute is located. + run_cell_copy : bool + True if the cell should be executed line by line, + False if the `debugcell` kernel function should be used. + focus_to_editor: bool + Whether to give focus to the editor after debugging the cell. If + False, focus is given to the console. + + Returns + ------- + None. + """ + self.get_widget().debug_cell(code, cell_name, filename, run_cell_copy, + focus_to_editor) + + def execute_code(self, lines, current_client=True, clear_variables=False): + """ + Execute code instructions. + + Parameters + ---------- + lines : str + Code lines to execute. + current_client : bool, optional + True if the execution should be done in the current client. + The default is True. + clear_variables : bool, optional + True if before the execution the variables should be cleared. + The default is False. + + Returns + ------- + None. + """ + self.get_widget().execute_code( + lines, + current_client=current_client, + clear_variables=clear_variables) + + def execute_code_and_focus_editor(self, lines, focus_to_editor=True): + """ + Execute lines in IPython console and eventually set focus + to the Editor. + """ + self.execute_code(lines) + if focus_to_editor and self.get_plugin(Plugins.Editor): + self._switch_to_editor() + else: + self.switch_to_plugin() + + def stop_debugging(self): + """Stop debugging in the current console.""" + self.get_widget().stop_debugging() + + def get_pdb_state(self): + """Get debugging state of the current console.""" + return self.get_widget().get_pdb_state() + + def get_pdb_last_step(self): + """Get last pdb step of the current console.""" + return self.get_widget().get_pdb_last_step() + + def pdb_execute_command(self, command): + """ + Send command to the pdb kernel if possible. + + Parameters + ---------- + command : str + Command to execute by the pdb kernel. + + Returns + ------- + None. + """ + self.get_widget().pdb_execute_command(command) + + def print_debug_file_msg(self): + """ + Print message in the current console when a file can't be closed. + + Returns + ------- + None. + """ + self.get_widget().print_debug_file_msg() + + # ---- For working directory and path management + def set_current_client_working_directory(self, directory): + """ + Set current client working directory. + + Parameters + ---------- + directory : str + Path for the new current working directory. + + Returns + ------- + None. + """ + self.get_widget().set_current_client_working_directory(directory) + + def set_working_directory(self, dirname): + """ + Set current working directory for the `workingdirectory` and `explorer` + plugins. + + Parameters + ---------- + dirname : str + Path to the new current working directory. + + Returns + ------- + None. + """ + self.get_widget().set_working_directory(dirname) + + def update_working_directory(self): + """Update working directory to console current working directory.""" + self.get_widget().update_working_directory() + + def update_path(self, path_dict, new_path_dict): + """ + Update path on consoles. + + Both parameters have as keys paths and as value if the path + should be used/is active (True) or not (False) + + Parameters + ---------- + path_dict : dict + Corresponds to the previous state of the PYTHONPATH. + new_path_dict : dict + Corresponds to the new state of the PYTHONPATH. + + Returns + ------- + None. + """ + self.get_widget().update_path(path_dict, new_path_dict) + + def set_spyder_breakpoints(self): + """Set Spyder breakpoints into all clients""" + self.get_widget().set_spyder_breakpoints() + + def restart(self): + """ + Restart the console. + + This is needed when we switch projects to update PYTHONPATH + and the selected interpreter. + """ + self.get_widget().restart() + + def restart_kernel(self): + """ + Restart the current client's kernel. + + Returns + ------- + None. + """ + self.get_widget().restart_kernel() + + # ---- For documentation and help + def show_intro(self): + """Show intro to IPython help.""" + self.get_widget().show_intro() + + def show_guiref(self): + """Show qtconsole help.""" + self.get_widget().show_guiref() + + def show_quickref(self): + """Show IPython Cheat Sheet.""" + self.get_widget().show_quickref() diff --git a/spyder/plugins/ipythonconsole/utils/manager.py b/spyder/plugins/ipythonconsole/utils/manager.py index 1bc420fae4a..987629ed715 100644 --- a/spyder/plugins/ipythonconsole/utils/manager.py +++ b/spyder/plugins/ipythonconsole/utils/manager.py @@ -1,120 +1,120 @@ -# -*- coding: utf-8 -*- -# ----------------------------------------------------------------------------- -# Copyright (c) 2009- Spyder Project Contributors -# -# Distributed under the terms of the MIT License -# (see spyder/__init__.py for details) -# ----------------------------------------------------------------------------- - -"""Kernel Manager subclass.""" - -# Standard library imports -import os -import signal - -# Third party imports -from jupyter_client.utils import run_sync -import psutil -from qtconsole.manager import QtKernelManager - - -class SpyderKernelManager(QtKernelManager): - """ - Spyder kernels that live in a conda environment are now properly activated - with custom activation scripts located at plugins/ipythonconsole/scripts. - - However, on windows the batch script is terminated but not the kernel it - started so this subclass overrides the `_kill_kernel` method to properly - kill the started kernels by using psutil. - """ - - def __init__(self, *args, **kwargs): - self.shutting_down = False - return QtKernelManager.__init__(self, *args, **kwargs) - - @staticmethod - async def kill_proc_tree(pid, sig=signal.SIGTERM, include_parent=True, - timeout=None, on_terminate=None): - """ - Kill a process tree (including grandchildren) with sig and return a - (gone, still_alive) tuple. - - "on_terminate", if specified, is a callabck function which is called - as soon as a child terminates. - - This is an new method not present in QtKernelManager. - """ - assert pid != os.getpid() # Won't kill myself! - - # This is necessary to avoid showing an error when restarting the - # kernel after it failed to start in the first place. - # Fixes spyder-ide/spyder#11872 - try: - parent = psutil.Process(pid) - except psutil.NoSuchProcess: - return ([], []) - - children = parent.children(recursive=True) - - if include_parent: - children.append(parent) - - for child_process in children: - # This is necessary to avoid an error when restarting the - # kernel that started a PyQt5 application in the background. - # Fixes spyder-ide/spyder#13999 - try: - child_process.send_signal(sig) - except psutil.AccessDenied: - return ([], []) - - gone, alive = psutil.wait_procs( - children, - timeout=timeout, - callback=on_terminate, - ) - - return (gone, alive) - - async def _async_kill_kernel(self, restart: bool = False) -> None: - """Kill the running kernel. - Override private method of jupyter_client 7 to be able to correctly - close kernel that was started via a batch/bash script for correct conda - env activation. - """ - if self.has_kernel: - assert self.provisioner is not None - - # This is the additional line that was added to properly - # kill the kernel started by Spyder. - await self.kill_proc_tree(self.provisioner.process.pid) - - await self.provisioner.kill(restart=restart) - - # Wait until the kernel terminates. - import asyncio - try: - await asyncio.wait_for(self._async_wait(), timeout=5.0) - except asyncio.TimeoutError: - # Wait timed out, just log warning but continue - # - not much more we can do. - self.log.warning("Wait for final termination of kernel timed" - " out - continuing...") - pass - else: - # Process is no longer alive, wait and clear - if self.has_kernel: - await self.provisioner.wait() - - _kill_kernel = run_sync(_async_kill_kernel) - - async def _async_send_kernel_sigterm(self, restart: bool = False) -> None: - """similar to _kill_kernel, but with sigterm (not sigkill), but do not block""" - if self.has_kernel: - assert self.provisioner is not None - - # This is the line that was added to properly kill kernels started - # by Spyder. - await self.kill_proc_tree(self.provisioner.process.pid) - - _send_kernel_sigterm = run_sync(_async_send_kernel_sigterm) +# -*- coding: utf-8 -*- +# ----------------------------------------------------------------------------- +# Copyright (c) 2009- Spyder Project Contributors +# +# Distributed under the terms of the MIT License +# (see spyder/__init__.py for details) +# ----------------------------------------------------------------------------- + +"""Kernel Manager subclass.""" + +# Standard library imports +import os +import signal + +# Third party imports +from jupyter_client.utils import run_sync +import psutil +from qtconsole.manager import QtKernelManager + + +class SpyderKernelManager(QtKernelManager): + """ + Spyder kernels that live in a conda environment are now properly activated + with custom activation scripts located at plugins/ipythonconsole/scripts. + + However, on windows the batch script is terminated but not the kernel it + started so this subclass overrides the `_kill_kernel` method to properly + kill the started kernels by using psutil. + """ + + def __init__(self, *args, **kwargs): + self.shutting_down = False + return QtKernelManager.__init__(self, *args, **kwargs) + + @staticmethod + async def kill_proc_tree(pid, sig=signal.SIGTERM, include_parent=True, + timeout=None, on_terminate=None): + """ + Kill a process tree (including grandchildren) with sig and return a + (gone, still_alive) tuple. + + "on_terminate", if specified, is a callabck function which is called + as soon as a child terminates. + + This is an new method not present in QtKernelManager. + """ + assert pid != os.getpid() # Won't kill myself! + + # This is necessary to avoid showing an error when restarting the + # kernel after it failed to start in the first place. + # Fixes spyder-ide/spyder#11872 + try: + parent = psutil.Process(pid) + except psutil.NoSuchProcess: + return ([], []) + + children = parent.children(recursive=True) + + if include_parent: + children.append(parent) + + for child_process in children: + # This is necessary to avoid an error when restarting the + # kernel that started a PyQt5 application in the background. + # Fixes spyder-ide/spyder#13999 + try: + child_process.send_signal(sig) + except psutil.AccessDenied: + return ([], []) + + gone, alive = psutil.wait_procs( + children, + timeout=timeout, + callback=on_terminate, + ) + + return (gone, alive) + + async def _async_kill_kernel(self, restart: bool = False) -> None: + """Kill the running kernel. + Override private method of jupyter_client 7 to be able to correctly + close kernel that was started via a batch/bash script for correct conda + env activation. + """ + if self.has_kernel: + assert self.provisioner is not None + + # This is the additional line that was added to properly + # kill the kernel started by Spyder. + await self.kill_proc_tree(self.provisioner.process.pid) + + await self.provisioner.kill(restart=restart) + + # Wait until the kernel terminates. + import asyncio + try: + await asyncio.wait_for(self._async_wait(), timeout=5.0) + except asyncio.TimeoutError: + # Wait timed out, just log warning but continue + # - not much more we can do. + self.log.warning("Wait for final termination of kernel timed" + " out - continuing...") + pass + else: + # Process is no longer alive, wait and clear + if self.has_kernel: + await self.provisioner.wait() + + _kill_kernel = run_sync(_async_kill_kernel) + + async def _async_send_kernel_sigterm(self, restart: bool = False) -> None: + """similar to _kill_kernel, but with sigterm (not sigkill), but do not block""" + if self.has_kernel: + assert self.provisioner is not None + + # This is the line that was added to properly kill kernels started + # by Spyder. + await self.kill_proc_tree(self.provisioner.process.pid) + + _send_kernel_sigterm = run_sync(_async_send_kernel_sigterm) diff --git a/spyder/plugins/ipythonconsole/widgets/client.py b/spyder/plugins/ipythonconsole/widgets/client.py index 86efdf9626c..846f84497f4 100644 --- a/spyder/plugins/ipythonconsole/widgets/client.py +++ b/spyder/plugins/ipythonconsole/widgets/client.py @@ -1,937 +1,937 @@ -# -*- coding: utf-8 -*- -# ----------------------------------------------------------------------------- -# Copyright (c) 2009- Spyder Project Contributors -# -# Distributed under the terms of the MIT License -# (see spyder/__init__.py for details) -# ----------------------------------------------------------------------------- - - -""" -Client widget for the IPython Console. - -This is the widget used on all its tabs. -""" - -# Standard library imports. -import logging -import os -import os.path as osp -import re -from string import Template -import time - -# Third party imports (qtpy) -from qtpy.QtCore import QUrl, QTimer, Signal, Slot, QThread -from qtpy.QtWidgets import (QMessageBox, QVBoxLayout, QWidget) - -# Local imports -from spyder.api.translations import get_translation -from spyder.api.widgets.mixins import SpyderWidgetMixin -from spyder.config.base import ( - get_home_dir, get_module_source_path, running_under_pytest) -from spyder.utils.icon_manager import ima -from spyder.utils import sourcecode -from spyder.utils.image_path_manager import get_image_path -from spyder.utils.installers import InstallerIPythonKernelError -from spyder.utils.encoding import get_coding -from spyder.utils.environ import RemoteEnvDialog -from spyder.utils.palette import QStylePalette -from spyder.utils.qthelpers import add_actions, DialogManager -from spyder.py3compat import to_text_string -from spyder.plugins.ipythonconsole.widgets import ShellWidget -from spyder.widgets.collectionseditor import CollectionsEditor -from spyder.widgets.mixins import SaveHistoryMixin - - -# Localization and logging -_ = get_translation('spyder') -logger = logging.getLogger(__name__) - -# ----------------------------------------------------------------------------- -# Templates -# ----------------------------------------------------------------------------- -# Using the same css file from the Help plugin for now. Maybe -# later it'll be a good idea to create a new one. -PLUGINS_PATH = get_module_source_path('spyder', 'plugins') - -CSS_PATH = osp.join(PLUGINS_PATH, 'help', 'utils', 'static', 'css') -TEMPLATES_PATH = osp.join( - PLUGINS_PATH, 'ipythonconsole', 'assets', 'templates') - -BLANK = open(osp.join(TEMPLATES_PATH, 'blank.html')).read() -LOADING = open(osp.join(TEMPLATES_PATH, 'loading.html')).read() -KERNEL_ERROR = open(osp.join(TEMPLATES_PATH, 'kernel_error.html')).read() - -try: - time.monotonic # time.monotonic new in 3.3 -except AttributeError: - time.monotonic = time.time - -# ---------------------------------------------------------------------------- -# Client widget -# ---------------------------------------------------------------------------- -class ClientWidget(QWidget, SaveHistoryMixin, SpyderWidgetMixin): - """ - Client widget for the IPython Console - - This widget is necessary to handle the interaction between the - plugin and each shell widget. - """ - - sig_append_to_history_requested = Signal(str, str) - sig_execution_state_changed = Signal() - - CONF_SECTION = 'ipython_console' - SEPARATOR = '{0}## ---({1})---'.format(os.linesep*2, time.ctime()) - INITHISTORY = ['# -*- coding: utf-8 -*-', - '# *** Spyder Python Console History Log ***', ] - - def __init__(self, parent, id_, - history_filename, config_options, - additional_options, interpreter_versions, - connection_file=None, hostname=None, - context_menu_actions=(), - menu_actions=None, - is_external_kernel=False, - is_spyder_kernel=True, - given_name=None, - give_focus=True, - options_button=None, - time_label=None, - show_elapsed_time=False, - reset_warning=True, - ask_before_restart=True, - ask_before_closing=False, - css_path=None, - handlers={}, - stderr_obj=None, - stdout_obj=None, - fault_obj=None): - super(ClientWidget, self).__init__(parent) - SaveHistoryMixin.__init__(self, history_filename) - - # --- Init attrs - self.container = parent - self.id_ = id_ - self.connection_file = connection_file - self.hostname = hostname - self.menu_actions = menu_actions - self.is_external_kernel = is_external_kernel - self.given_name = given_name - self.show_elapsed_time = show_elapsed_time - self.reset_warning = reset_warning - self.ask_before_restart = ask_before_restart - self.ask_before_closing = ask_before_closing - - # --- Other attrs - self.context_menu_actions = context_menu_actions - self.time_label = time_label - self.options_button = options_button - self.history = [] - self.allow_rename = True - self.is_error_shown = False - self.error_text = None - self.restart_thread = None - self.give_focus = give_focus - - if css_path is None: - self.css_path = CSS_PATH - else: - self.css_path = css_path - - # --- Widgets - self.shellwidget = ShellWidget( - config=config_options, - ipyclient=self, - additional_options=additional_options, - interpreter_versions=interpreter_versions, - is_external_kernel=is_external_kernel, - is_spyder_kernel=is_spyder_kernel, - handlers=handlers, - local_kernel=True - ) - self.infowidget = self.container.infowidget - self.blank_page = self._create_blank_page() - self.loading_page = self._create_loading_page() - # To keep a reference to the page to be displayed - # in infowidget - self.info_page = None - self._before_prompt_is_ready() - - # Elapsed time - self.t0 = time.monotonic() - self.timer = QTimer(self) - - # --- Layout - self.layout = QVBoxLayout() - self.layout.setContentsMargins(0, 0, 0, 0) - self.layout.addWidget(self.shellwidget) - if self.infowidget is not None: - self.layout.addWidget(self.infowidget) - self.setLayout(self.layout) - - # --- Exit function - self.exit_callback = lambda: self.container.close_client(client=self) - - # --- Dialog manager - self.dialog_manager = DialogManager() - - # --- Standard files handling - self.stderr_obj = stderr_obj - self.stdout_obj = stdout_obj - self.fault_obj = fault_obj - self.std_poll_timer = None - if self.stderr_obj is not None or self.stdout_obj is not None: - self.std_poll_timer = QTimer(self) - self.std_poll_timer.timeout.connect(self.poll_std_file_change) - self.std_poll_timer.setInterval(1000) - self.std_poll_timer.start() - self.shellwidget.executed.connect(self.poll_std_file_change) - - self.start_successful = False - - def __del__(self): - """Close threads to avoid segfault.""" - if (self.restart_thread is not None - and self.restart_thread.isRunning()): - self.restart_thread.quit() - self.restart_thread.wait() - - # ----- Private methods --------------------------------------------------- - def _before_prompt_is_ready(self, show_loading_page=True): - """Configuration before kernel is connected.""" - if show_loading_page: - self._show_loading_page() - self.shellwidget.sig_prompt_ready.connect( - self._when_prompt_is_ready) - # If remote execution, the loading page should be hidden as well - self.shellwidget.sig_remote_execute.connect( - self._when_prompt_is_ready) - - def _when_prompt_is_ready(self): - """Configuration after the prompt is shown.""" - self.start_successful = True - # To hide the loading page - self._hide_loading_page() - - # Show possible errors when setting Matplotlib backend - self._show_mpl_backend_errors() - - # To show if special console is valid - self._check_special_console_error() - - # Set the initial current working directory - self._set_initial_cwd() - - self.shellwidget.sig_prompt_ready.disconnect( - self._when_prompt_is_ready) - self.shellwidget.sig_remote_execute.disconnect( - self._when_prompt_is_ready) - - # It's necessary to do this at this point to avoid giving - # focus to _control at startup. - self._connect_control_signals() - - if self.give_focus: - self.shellwidget._control.setFocus() - - def _create_loading_page(self): - """Create html page to show while the kernel is starting""" - loading_template = Template(LOADING) - loading_img = get_image_path('loading_sprites') - if os.name == 'nt': - loading_img = loading_img.replace('\\', '/') - message = _("Connecting to kernel...") - page = loading_template.substitute(css_path=self.css_path, - loading_img=loading_img, - message=message) - return page - - def _create_blank_page(self): - """Create html page to show while the kernel is starting""" - loading_template = Template(BLANK) - page = loading_template.substitute(css_path=self.css_path) - return page - - def _show_loading_page(self): - """Show animation while the kernel is loading.""" - if self.infowidget is not None: - self.shellwidget.hide() - self.infowidget.show() - self.info_page = self.loading_page - self.set_info_page() - - def _hide_loading_page(self): - """Hide animation shown while the kernel is loading.""" - if self.infowidget is not None: - self.infowidget.hide() - self.info_page = self.blank_page - self.set_info_page() - self.shellwidget.show() - - def _read_stderr(self): - """Read the stderr file of the kernel.""" - # We need to read stderr_file as bytes to be able to - # detect its encoding with chardet - f = open(self.stderr_file, 'rb') - - try: - stderr_text = f.read() - - # This is needed to avoid showing an empty error message - # when the kernel takes too much time to start. - # See spyder-ide/spyder#8581. - if not stderr_text: - return '' - - # This is needed since the stderr file could be encoded - # in something different to utf-8. - # See spyder-ide/spyder#4191. - encoding = get_coding(stderr_text) - stderr_text = to_text_string(stderr_text, encoding) - return stderr_text - finally: - f.close() - - def _show_mpl_backend_errors(self): - """ - Show possible errors when setting the selected Matplotlib backend. - """ - if self.shellwidget.is_spyder_kernel: - self.shellwidget.call_kernel().show_mpl_backend_errors() - - def _check_special_console_error(self): - """Check if the dependecies for special consoles are available.""" - self.shellwidget.call_kernel( - callback=self._show_special_console_error - ).is_special_kernel_valid() - - def _show_special_console_error(self, missing_dependency): - if missing_dependency is not None: - error_message = _( - "Your Python environment or installation doesn't have the " - "{missing_dependency} module installed or it " - "occurred a problem importing it. Due to that, it is not " - "possible for Spyder to create this special console for " - "you." - ).format(missing_dependency=missing_dependency) - - self.show_kernel_error(error_message) - - def _abort_kernel_restart(self): - """ - Abort kernel restart if there are errors while starting it. - - We also ignore errors about comms, which are irrelevant. - """ - if self.start_successful: - return False - stderr = self.stderr_obj.get_contents() - if not stderr: - return False - # There is an error. If it is benign, ignore. - for line in stderr.splitlines(): - if line and not self.is_benign_error(line): - return True - return False - - def _connect_control_signals(self): - """Connect signals of control widgets.""" - control = self.shellwidget._control - page_control = self.shellwidget._page_control - - control.sig_focus_changed.connect( - self.container.sig_focus_changed) - page_control.sig_focus_changed.connect( - self.container.sig_focus_changed) - control.sig_visibility_changed.connect( - self.container.refresh_container) - page_control.sig_visibility_changed.connect( - self.container.refresh_container) - page_control.sig_show_find_widget_requested.connect( - self.container.find_widget.show) - - def _set_initial_cwd(self): - """Set initial cwd according to preferences.""" - logger.debug("Setting initial working directory") - cwd_path = get_home_dir() - project_path = self.container.get_active_project_path() - - # This is for the first client - if self.id_['int_id'] == '1': - if self.get_conf( - 'startup/use_project_or_home_directory', - section='workingdir' - ): - cwd_path = get_home_dir() - if project_path is not None: - cwd_path = project_path - elif self.get_conf( - 'startup/use_fixed_directory', - section='workingdir' - ): - cwd_path = self.get_conf( - 'startup/fixed_directory', - default=get_home_dir(), - section='workingdir' - ) - else: - # For new clients - if self.get_conf( - 'console/use_project_or_home_directory', - section='workingdir' - ): - cwd_path = get_home_dir() - if project_path is not None: - cwd_path = project_path - elif self.get_conf('console/use_cwd', section='workingdir'): - cwd_path = self.container.get_working_directory() - elif self.get_conf( - 'console/use_fixed_directory', - section='workingdir' - ): - cwd_path = self.get_conf( - 'console/fixed_directory', - default=get_home_dir(), - section='workingdir' - ) - - if osp.isdir(cwd_path): - self.shellwidget.set_cwd(cwd_path) - - # ----- Public API -------------------------------------------------------- - @property - def kernel_id(self): - """Get kernel id.""" - if self.connection_file is not None: - json_file = osp.basename(self.connection_file) - return json_file.split('.json')[0] - - def remove_std_files(self, is_last_client=True): - """Remove stderr_file associated with the client.""" - try: - self.shellwidget.executed.disconnect(self.poll_std_file_change) +# -*- coding: utf-8 -*- +# ----------------------------------------------------------------------------- +# Copyright (c) 2009- Spyder Project Contributors +# +# Distributed under the terms of the MIT License +# (see spyder/__init__.py for details) +# ----------------------------------------------------------------------------- + + +""" +Client widget for the IPython Console. + +This is the widget used on all its tabs. +""" + +# Standard library imports. +import logging +import os +import os.path as osp +import re +from string import Template +import time + +# Third party imports (qtpy) +from qtpy.QtCore import QUrl, QTimer, Signal, Slot, QThread +from qtpy.QtWidgets import (QMessageBox, QVBoxLayout, QWidget) + +# Local imports +from spyder.api.translations import get_translation +from spyder.api.widgets.mixins import SpyderWidgetMixin +from spyder.config.base import ( + get_home_dir, get_module_source_path, running_under_pytest) +from spyder.utils.icon_manager import ima +from spyder.utils import sourcecode +from spyder.utils.image_path_manager import get_image_path +from spyder.utils.installers import InstallerIPythonKernelError +from spyder.utils.encoding import get_coding +from spyder.utils.environ import RemoteEnvDialog +from spyder.utils.palette import QStylePalette +from spyder.utils.qthelpers import add_actions, DialogManager +from spyder.py3compat import to_text_string +from spyder.plugins.ipythonconsole.widgets import ShellWidget +from spyder.widgets.collectionseditor import CollectionsEditor +from spyder.widgets.mixins import SaveHistoryMixin + + +# Localization and logging +_ = get_translation('spyder') +logger = logging.getLogger(__name__) + +# ----------------------------------------------------------------------------- +# Templates +# ----------------------------------------------------------------------------- +# Using the same css file from the Help plugin for now. Maybe +# later it'll be a good idea to create a new one. +PLUGINS_PATH = get_module_source_path('spyder', 'plugins') + +CSS_PATH = osp.join(PLUGINS_PATH, 'help', 'utils', 'static', 'css') +TEMPLATES_PATH = osp.join( + PLUGINS_PATH, 'ipythonconsole', 'assets', 'templates') + +BLANK = open(osp.join(TEMPLATES_PATH, 'blank.html')).read() +LOADING = open(osp.join(TEMPLATES_PATH, 'loading.html')).read() +KERNEL_ERROR = open(osp.join(TEMPLATES_PATH, 'kernel_error.html')).read() + +try: + time.monotonic # time.monotonic new in 3.3 +except AttributeError: + time.monotonic = time.time + +# ---------------------------------------------------------------------------- +# Client widget +# ---------------------------------------------------------------------------- +class ClientWidget(QWidget, SaveHistoryMixin, SpyderWidgetMixin): + """ + Client widget for the IPython Console + + This widget is necessary to handle the interaction between the + plugin and each shell widget. + """ + + sig_append_to_history_requested = Signal(str, str) + sig_execution_state_changed = Signal() + + CONF_SECTION = 'ipython_console' + SEPARATOR = '{0}## ---({1})---'.format(os.linesep*2, time.ctime()) + INITHISTORY = ['# -*- coding: utf-8 -*-', + '# *** Spyder Python Console History Log ***', ] + + def __init__(self, parent, id_, + history_filename, config_options, + additional_options, interpreter_versions, + connection_file=None, hostname=None, + context_menu_actions=(), + menu_actions=None, + is_external_kernel=False, + is_spyder_kernel=True, + given_name=None, + give_focus=True, + options_button=None, + time_label=None, + show_elapsed_time=False, + reset_warning=True, + ask_before_restart=True, + ask_before_closing=False, + css_path=None, + handlers={}, + stderr_obj=None, + stdout_obj=None, + fault_obj=None): + super(ClientWidget, self).__init__(parent) + SaveHistoryMixin.__init__(self, history_filename) + + # --- Init attrs + self.container = parent + self.id_ = id_ + self.connection_file = connection_file + self.hostname = hostname + self.menu_actions = menu_actions + self.is_external_kernel = is_external_kernel + self.given_name = given_name + self.show_elapsed_time = show_elapsed_time + self.reset_warning = reset_warning + self.ask_before_restart = ask_before_restart + self.ask_before_closing = ask_before_closing + + # --- Other attrs + self.context_menu_actions = context_menu_actions + self.time_label = time_label + self.options_button = options_button + self.history = [] + self.allow_rename = True + self.is_error_shown = False + self.error_text = None + self.restart_thread = None + self.give_focus = give_focus + + if css_path is None: + self.css_path = CSS_PATH + else: + self.css_path = css_path + + # --- Widgets + self.shellwidget = ShellWidget( + config=config_options, + ipyclient=self, + additional_options=additional_options, + interpreter_versions=interpreter_versions, + is_external_kernel=is_external_kernel, + is_spyder_kernel=is_spyder_kernel, + handlers=handlers, + local_kernel=True + ) + self.infowidget = self.container.infowidget + self.blank_page = self._create_blank_page() + self.loading_page = self._create_loading_page() + # To keep a reference to the page to be displayed + # in infowidget + self.info_page = None + self._before_prompt_is_ready() + + # Elapsed time + self.t0 = time.monotonic() + self.timer = QTimer(self) + + # --- Layout + self.layout = QVBoxLayout() + self.layout.setContentsMargins(0, 0, 0, 0) + self.layout.addWidget(self.shellwidget) + if self.infowidget is not None: + self.layout.addWidget(self.infowidget) + self.setLayout(self.layout) + + # --- Exit function + self.exit_callback = lambda: self.container.close_client(client=self) + + # --- Dialog manager + self.dialog_manager = DialogManager() + + # --- Standard files handling + self.stderr_obj = stderr_obj + self.stdout_obj = stdout_obj + self.fault_obj = fault_obj + self.std_poll_timer = None + if self.stderr_obj is not None or self.stdout_obj is not None: + self.std_poll_timer = QTimer(self) + self.std_poll_timer.timeout.connect(self.poll_std_file_change) + self.std_poll_timer.setInterval(1000) + self.std_poll_timer.start() + self.shellwidget.executed.connect(self.poll_std_file_change) + + self.start_successful = False + + def __del__(self): + """Close threads to avoid segfault.""" + if (self.restart_thread is not None + and self.restart_thread.isRunning()): + self.restart_thread.quit() + self.restart_thread.wait() + + # ----- Private methods --------------------------------------------------- + def _before_prompt_is_ready(self, show_loading_page=True): + """Configuration before kernel is connected.""" + if show_loading_page: + self._show_loading_page() + self.shellwidget.sig_prompt_ready.connect( + self._when_prompt_is_ready) + # If remote execution, the loading page should be hidden as well + self.shellwidget.sig_remote_execute.connect( + self._when_prompt_is_ready) + + def _when_prompt_is_ready(self): + """Configuration after the prompt is shown.""" + self.start_successful = True + # To hide the loading page + self._hide_loading_page() + + # Show possible errors when setting Matplotlib backend + self._show_mpl_backend_errors() + + # To show if special console is valid + self._check_special_console_error() + + # Set the initial current working directory + self._set_initial_cwd() + + self.shellwidget.sig_prompt_ready.disconnect( + self._when_prompt_is_ready) + self.shellwidget.sig_remote_execute.disconnect( + self._when_prompt_is_ready) + + # It's necessary to do this at this point to avoid giving + # focus to _control at startup. + self._connect_control_signals() + + if self.give_focus: + self.shellwidget._control.setFocus() + + def _create_loading_page(self): + """Create html page to show while the kernel is starting""" + loading_template = Template(LOADING) + loading_img = get_image_path('loading_sprites') + if os.name == 'nt': + loading_img = loading_img.replace('\\', '/') + message = _("Connecting to kernel...") + page = loading_template.substitute(css_path=self.css_path, + loading_img=loading_img, + message=message) + return page + + def _create_blank_page(self): + """Create html page to show while the kernel is starting""" + loading_template = Template(BLANK) + page = loading_template.substitute(css_path=self.css_path) + return page + + def _show_loading_page(self): + """Show animation while the kernel is loading.""" + if self.infowidget is not None: + self.shellwidget.hide() + self.infowidget.show() + self.info_page = self.loading_page + self.set_info_page() + + def _hide_loading_page(self): + """Hide animation shown while the kernel is loading.""" + if self.infowidget is not None: + self.infowidget.hide() + self.info_page = self.blank_page + self.set_info_page() + self.shellwidget.show() + + def _read_stderr(self): + """Read the stderr file of the kernel.""" + # We need to read stderr_file as bytes to be able to + # detect its encoding with chardet + f = open(self.stderr_file, 'rb') + + try: + stderr_text = f.read() + + # This is needed to avoid showing an empty error message + # when the kernel takes too much time to start. + # See spyder-ide/spyder#8581. + if not stderr_text: + return '' + + # This is needed since the stderr file could be encoded + # in something different to utf-8. + # See spyder-ide/spyder#4191. + encoding = get_coding(stderr_text) + stderr_text = to_text_string(stderr_text, encoding) + return stderr_text + finally: + f.close() + + def _show_mpl_backend_errors(self): + """ + Show possible errors when setting the selected Matplotlib backend. + """ + if self.shellwidget.is_spyder_kernel: + self.shellwidget.call_kernel().show_mpl_backend_errors() + + def _check_special_console_error(self): + """Check if the dependecies for special consoles are available.""" + self.shellwidget.call_kernel( + callback=self._show_special_console_error + ).is_special_kernel_valid() + + def _show_special_console_error(self, missing_dependency): + if missing_dependency is not None: + error_message = _( + "Your Python environment or installation doesn't have the " + "{missing_dependency} module installed or it " + "occurred a problem importing it. Due to that, it is not " + "possible for Spyder to create this special console for " + "you." + ).format(missing_dependency=missing_dependency) + + self.show_kernel_error(error_message) + + def _abort_kernel_restart(self): + """ + Abort kernel restart if there are errors while starting it. + + We also ignore errors about comms, which are irrelevant. + """ + if self.start_successful: + return False + stderr = self.stderr_obj.get_contents() + if not stderr: + return False + # There is an error. If it is benign, ignore. + for line in stderr.splitlines(): + if line and not self.is_benign_error(line): + return True + return False + + def _connect_control_signals(self): + """Connect signals of control widgets.""" + control = self.shellwidget._control + page_control = self.shellwidget._page_control + + control.sig_focus_changed.connect( + self.container.sig_focus_changed) + page_control.sig_focus_changed.connect( + self.container.sig_focus_changed) + control.sig_visibility_changed.connect( + self.container.refresh_container) + page_control.sig_visibility_changed.connect( + self.container.refresh_container) + page_control.sig_show_find_widget_requested.connect( + self.container.find_widget.show) + + def _set_initial_cwd(self): + """Set initial cwd according to preferences.""" + logger.debug("Setting initial working directory") + cwd_path = get_home_dir() + project_path = self.container.get_active_project_path() + + # This is for the first client + if self.id_['int_id'] == '1': + if self.get_conf( + 'startup/use_project_or_home_directory', + section='workingdir' + ): + cwd_path = get_home_dir() + if project_path is not None: + cwd_path = project_path + elif self.get_conf( + 'startup/use_fixed_directory', + section='workingdir' + ): + cwd_path = self.get_conf( + 'startup/fixed_directory', + default=get_home_dir(), + section='workingdir' + ) + else: + # For new clients + if self.get_conf( + 'console/use_project_or_home_directory', + section='workingdir' + ): + cwd_path = get_home_dir() + if project_path is not None: + cwd_path = project_path + elif self.get_conf('console/use_cwd', section='workingdir'): + cwd_path = self.container.get_working_directory() + elif self.get_conf( + 'console/use_fixed_directory', + section='workingdir' + ): + cwd_path = self.get_conf( + 'console/fixed_directory', + default=get_home_dir(), + section='workingdir' + ) + + if osp.isdir(cwd_path): + self.shellwidget.set_cwd(cwd_path) + + # ----- Public API -------------------------------------------------------- + @property + def kernel_id(self): + """Get kernel id.""" + if self.connection_file is not None: + json_file = osp.basename(self.connection_file) + return json_file.split('.json')[0] + + def remove_std_files(self, is_last_client=True): + """Remove stderr_file associated with the client.""" + try: + self.shellwidget.executed.disconnect(self.poll_std_file_change) except (TypeError, ValueError): - pass - if self.std_poll_timer is not None: - self.std_poll_timer.stop() - if is_last_client: - if self.stderr_obj is not None: - self.stderr_obj.remove() - if self.stdout_obj is not None: - self.stdout_obj.remove() - if self.fault_obj is not None: - self.fault_obj.remove() - - @Slot() - def poll_std_file_change(self): - """Check if the stderr or stdout file just changed.""" - self.shellwidget.call_kernel().flush_std() - starting = self.shellwidget._starting - if self.stderr_obj is not None: - stderr = self.stderr_obj.poll_file_change() - if stderr: - if self.is_benign_error(stderr): - return - if self.shellwidget.isHidden(): - # Avoid printing the same thing again - if self.error_text != '%s' % stderr: - full_stderr = self.stderr_obj.get_contents() - self.show_kernel_error('%s' % full_stderr) - if starting: - self.shellwidget.banner = ( - stderr + '\n' + self.shellwidget.banner) - else: - self.shellwidget._append_plain_text( - '\n' + stderr, before_prompt=True) - - if self.stdout_obj is not None: - stdout = self.stdout_obj.poll_file_change() - if stdout: - if starting: - self.shellwidget.banner = ( - stdout + '\n' + self.shellwidget.banner) - else: - self.shellwidget._append_plain_text( - '\n' + stdout, before_prompt=True) - - def configure_shellwidget(self, give_focus=True): - """Configure shellwidget after kernel is connected.""" - self.give_focus = give_focus - - # Make sure the kernel sends the comm config over - self.shellwidget.call_kernel()._send_comm_config() - - # Set exit callback - self.shellwidget.set_exit_callback() - - # To save history - self.shellwidget.executing.connect(self.add_to_history) - - # For Mayavi to run correctly - self.shellwidget.executing.connect( - self.shellwidget.set_backend_for_mayavi) - - # To update history after execution - self.shellwidget.executed.connect(self.update_history) - - # To update the Variable Explorer after execution - self.shellwidget.executed.connect( - self.shellwidget.refresh_namespacebrowser) - - # To enable the stop button when executing a process - self.shellwidget.executing.connect( - self.sig_execution_state_changed) - - # To disable the stop button after execution stopped - self.shellwidget.executed.connect( - self.sig_execution_state_changed) - - # To show kernel restarted/died messages - self.shellwidget.sig_kernel_restarted_message.connect( - self.kernel_restarted_message) - self.shellwidget.sig_kernel_restarted.connect( - self._finalise_restart) - - # To correctly change Matplotlib backend interactively - self.shellwidget.executing.connect( - self.shellwidget.change_mpl_backend) - - # To show env and sys.path contents - self.shellwidget.sig_show_syspath.connect(self.show_syspath) - self.shellwidget.sig_show_env.connect(self.show_env) - - # To sync with working directory toolbar - self.shellwidget.executed.connect(self.shellwidget.update_cwd) - - # To apply style - self.set_color_scheme(self.shellwidget.syntax_style, reset=False) - - if self.fault_obj is not None: - # To display faulthandler - self.shellwidget.call_kernel().enable_faulthandler( - self.fault_obj.filename) - - def add_to_history(self, command): - """Add command to history""" - if self.shellwidget.is_debugging(): - return - return super(ClientWidget, self).add_to_history(command) - - def is_client_executing(self): - return (self.shellwidget._executing or - self.shellwidget.is_waiting_pdb_input()) - - @Slot() - def stop_button_click_handler(self): - """Method to handle what to do when the stop button is pressed""" - # Interrupt computations or stop debugging - if not self.shellwidget.is_waiting_pdb_input(): - self.interrupt_kernel() - else: - self.shellwidget.pdb_execute_command('exit') - - def show_kernel_error(self, error): - """Show kernel initialization errors in infowidget.""" - self.error_text = error - - if self.is_benign_error(error): - return - - InstallerIPythonKernelError(error) - - # Replace end of line chars with
- eol = sourcecode.get_eol_chars(error) - if eol: - error = error.replace(eol, '
') - - # Don't break lines in hyphens - # From https://stackoverflow.com/q/7691569/438386 - error = error.replace('-', '‑') - - # Create error page - message = _("An error ocurred while starting the kernel") - kernel_error_template = Template(KERNEL_ERROR) - self.info_page = kernel_error_template.substitute( - css_path=self.css_path, - message=message, - error=error) - - # Show error - if self.infowidget is not None: - self.set_info_page() - self.shellwidget.hide() - self.infowidget.show() - - # Tell the client we're in error mode - self.is_error_shown = True - - # Stop shellwidget - self.shellwidget.shutdown() - self.remove_std_files(is_last_client=False) - - def is_benign_error(self, error): - """Decide if an error is benign in order to filter it.""" - benign_errors = [ - # Error when switching from the Qt5 backend to the Tk one. - # See spyder-ide/spyder#17488 - "KeyboardInterrupt caught in kernel", - "QSocketNotifier: Multiple socket notifiers for same socket", - # Error when switching from the Tk backend to the Qt5 one. - # See spyder-ide/spyder#17488 - "Tcl_AsyncDelete async handler deleted by the wrong thread", - "error in background error handler:", - " while executing", - '"::tcl::Bgerror', - # Avoid showing this warning because it was up to the user to - # disable secure writes. - "WARNING: Insecure writes have been enabled via environment", - # Old error - "No such comm" - ] - - return any([err in error for err in benign_errors]) - - def get_name(self): - """Return client name""" - if self.given_name is None: - # Name according to host - if self.hostname is None: - name = _("Console") - else: - name = self.hostname - # Adding id to name - client_id = self.id_['int_id'] + u'/' + self.id_['str_id'] - name = name + u' ' + client_id - elif self.given_name in ["Pylab", "SymPy", "Cython"]: - client_id = self.id_['int_id'] + u'/' + self.id_['str_id'] - name = self.given_name + u' ' + client_id - else: - name = self.given_name + u'/' + self.id_['str_id'] - return name - - def get_control(self): - """Return the text widget (or similar) to give focus to""" - # page_control is the widget used for paging - page_control = self.shellwidget._page_control - if page_control and page_control.isVisible(): - return page_control - else: - return self.shellwidget._control - - def get_kernel(self): - """Get kernel associated with this client""" - return self.shellwidget.kernel_manager - - def add_actions_to_context_menu(self, menu): - """Add actions to IPython widget context menu""" - add_actions(menu, self.context_menu_actions) - - return menu - - def set_font(self, font): - """Set IPython widget's font""" - self.shellwidget._control.setFont(font) - self.shellwidget.font = font - - def set_color_scheme(self, color_scheme, reset=True): - """Set IPython color scheme.""" - # Needed to handle not initialized kernel_client - # See spyder-ide/spyder#6996. - try: - self.shellwidget.set_color_scheme(color_scheme, reset) - except AttributeError: - pass - - def shutdown(self, is_last_client): - """Shutdown connection and kernel if needed.""" - self.dialog_manager.close_all() - if (self.restart_thread is not None - and self.restart_thread.isRunning()): - self.restart_thread.finished.disconnect() - self.restart_thread.quit() - self.restart_thread.wait() - shutdown_kernel = ( - is_last_client and not self.is_external_kernel - and not self.is_error_shown) - self.shellwidget.shutdown(shutdown_kernel) - self.remove_std_files(shutdown_kernel) - - def interrupt_kernel(self): - """Interrupt the associanted Spyder kernel if it's running""" - # Needed to prevent a crash when a kernel is not running. - # See spyder-ide/spyder#6299. - try: - self.shellwidget.request_interrupt_kernel() - except RuntimeError: - pass - - @Slot() - def restart_kernel(self): - """ - Restart the associated kernel. - - Took this code from the qtconsole project - Licensed under the BSD license - """ - sw = self.shellwidget - - if not running_under_pytest() and self.ask_before_restart: - message = _('Are you sure you want to restart the kernel?') - buttons = QMessageBox.Yes | QMessageBox.No - result = QMessageBox.question(self, _('Restart kernel?'), - message, buttons) - else: - result = None - - if (result == QMessageBox.Yes or - running_under_pytest() or - not self.ask_before_restart): - if sw.kernel_manager: - if self.infowidget is not None: - if self.infowidget.isVisible(): - self.infowidget.hide() - - if self._abort_kernel_restart(): - sw.spyder_kernel_comm.close() - return - - self._show_loading_page() - - # Close comm - sw.spyder_kernel_comm.close() - - # Stop autorestart mechanism - sw.kernel_manager.stop_restarter() - sw.kernel_manager.autorestart = False - - # Reconfigure client before the new kernel is connected again. - self._before_prompt_is_ready(show_loading_page=False) - - # Create and run restarting thread - if (self.restart_thread is not None - and self.restart_thread.isRunning()): - self.restart_thread.finished.disconnect() - self.restart_thread.quit() - self.restart_thread.wait() - self.restart_thread = QThread(None) - self.restart_thread.run = self._restart_thread_main - self.restart_thread.error = None - self.restart_thread.finished.connect( - lambda: self._finalise_restart(True)) - self.restart_thread.start() - - else: - sw._append_plain_text( - _('Cannot restart a kernel not started by Spyder\n'), - before_prompt=True - ) - self._hide_loading_page() - - def _restart_thread_main(self): - """Restart the kernel in a thread.""" - try: - self.shellwidget.kernel_manager.restart_kernel( - stderr=self.stderr_obj.handle, - stdout=self.stdout_obj.handle) - except RuntimeError as e: - self.restart_thread.error = e - - def _finalise_restart(self, reset=False): - """Finishes the restarting of the kernel.""" - sw = self.shellwidget - - if self._abort_kernel_restart(): - sw.spyder_kernel_comm.close() - return - - if self.restart_thread and self.restart_thread.error is not None: - sw._append_plain_text( - _('Error restarting kernel: %s\n') % self.restart_thread.error, - before_prompt=True - ) - else: - if self.fault_obj is not None: - fault = self.fault_obj.get_contents() - if fault: - fault = self.filter_fault(fault) - self.shellwidget._append_plain_text( - '\n' + fault, before_prompt=True) - - # Reset Pdb state and reopen comm - sw._pdb_in_loop = False - sw.spyder_kernel_comm.remove() - try: - sw.spyder_kernel_comm.open_comm(sw.kernel_client) - except AttributeError: - # An error occurred while opening our comm channel. - # Aborting! - return - - # Start autorestart mechanism - sw.kernel_manager.autorestart = True - sw.kernel_manager.start_restarter() - - # For spyder-ide/spyder#6235, IPython was changing the - # setting of %colors on windows by assuming it was using a - # dark background. This corrects it based on the scheme. - self.set_color_scheme(sw.syntax_style, reset=reset) - sw._append_html(_("
Restarting kernel...
"), - before_prompt=True) - sw.insert_horizontal_ruler() - if self.fault_obj is not None: - self.shellwidget.call_kernel().enable_faulthandler( - self.fault_obj.filename) - - self._hide_loading_page() - self.restart_thread = None - self.sig_execution_state_changed.emit() - - def filter_fault(self, fault): - """Get a fault from a previous session.""" - thread_regex = ( - r"(Current thread|Thread) " - r"(0x[\da-f]+) \(most recent call first\):" - r"(?:.|\r\n|\r|\n)+?(?=Current thread|Thread|\Z)") - # Keep line for future improvments - # files_regex = r"File \"([^\"]+)\", line (\d+) in (\S+)" - - main_re = "Main thread id:(?:\r\n|\r|\n)(0x[0-9a-f]+)" - main_id = 0 - for match in re.finditer(main_re, fault): - main_id = int(match.group(1), base=16) - - system_re = ("System threads ids:" - "(?:\r\n|\r|\n)(0x[0-9a-f]+(?: 0x[0-9a-f]+)+)") - ignore_ids = [] - start_idx = 0 - for match in re.finditer(system_re, fault): - ignore_ids = [int(i, base=16) for i in match.group(1).split()] - start_idx = match.span()[1] - text = "" - for idx, match in enumerate(re.finditer(thread_regex, fault)): - if idx == 0: - text += fault[start_idx:match.span()[0]] - thread_id = int(match.group(2), base=16) - if thread_id != main_id: - if thread_id in ignore_ids: - continue - if "wurlitzer.py" in match.group(0): - # Wurlitzer threads are launched later - continue - text += "\n" + match.group(0) + "\n" - else: - try: - pattern = (r".*(?:/IPython/core/interactiveshell\.py|" - r"\\IPython\\core\\interactiveshell\.py).*") - match_internal = next(re.finditer(pattern, match.group(0))) - end_idx = match_internal.span()[0] - except StopIteration: - end_idx = None - text += "\nMain thread:\n" + match.group(0)[:end_idx] + "\n" - return text - - @Slot(str) - def kernel_restarted_message(self, msg): - """Show kernel restarted/died messages.""" - if self.stderr_obj is not None: - # If there are kernel creation errors, jupyter_client will - # try to restart the kernel and qtconsole prints a - # message about it. - # So we read the kernel's stderr_file and display its - # contents in the client instead of the usual message shown - # by qtconsole. - self.poll_std_file_change() - else: - self.shellwidget._append_html("
%s

" % msg, - before_prompt=False) - - @Slot() - def enter_array_inline(self): - """Enter and show the array builder on inline mode.""" - self.shellwidget._control.enter_array_inline() - - @Slot() - def enter_array_table(self): - """Enter and show the array builder on table.""" - self.shellwidget._control.enter_array_table() - - @Slot() - def inspect_object(self): - """Show how to inspect an object with our Help plugin""" - self.shellwidget._control.inspect_current_object() - - @Slot() - def clear_line(self): - """Clear a console line""" - self.shellwidget._keyboard_quit() - - @Slot() - def clear_console(self): - """Clear the whole console""" - self.shellwidget.clear_console() - - @Slot() - def reset_namespace(self): - """Resets the namespace by removing all names defined by the user""" - self.shellwidget.reset_namespace(warning=self.reset_warning, - message=True) - - def update_history(self): - self.history = self.shellwidget._history - - @Slot(object) - def show_syspath(self, syspath): - """Show sys.path contents.""" - if syspath is not None: - editor = CollectionsEditor(self) - editor.setup(syspath, title="sys.path contents", readonly=True, - icon=ima.icon('syspath')) - self.dialog_manager.show(editor) - else: - return - - @Slot(object) - def show_env(self, env): - """Show environment variables.""" - self.dialog_manager.show(RemoteEnvDialog(env, parent=self)) - - def show_time(self, end=False): - """Text to show in time_label.""" - if self.time_label is None: - return - - elapsed_time = time.monotonic() - self.t0 - # System time changed to past date, so reset start. - if elapsed_time < 0: - self.t0 = time.monotonic() - elapsed_time = 0 - if elapsed_time > 24 * 3600: # More than a day...! - fmt = "%d %H:%M:%S" - else: - fmt = "%H:%M:%S" - if end: - color = QStylePalette.COLOR_TEXT_3 - else: - color = QStylePalette.COLOR_ACCENT_4 - text = "%s" \ - "" % (color, - time.strftime(fmt, time.gmtime(elapsed_time))) - if self.show_elapsed_time: - self.time_label.setText(text) - else: - self.time_label.setText("") - - @Slot(bool) - def set_show_elapsed_time(self, state): - """Slot to show/hide elapsed time label.""" - self.show_elapsed_time = state - - def set_info_page(self): - """Set current info_page.""" - if self.infowidget is not None and self.info_page is not None: - self.infowidget.setHtml( - self.info_page, - QUrl.fromLocalFile(self.css_path) - ) + pass + if self.std_poll_timer is not None: + self.std_poll_timer.stop() + if is_last_client: + if self.stderr_obj is not None: + self.stderr_obj.remove() + if self.stdout_obj is not None: + self.stdout_obj.remove() + if self.fault_obj is not None: + self.fault_obj.remove() + + @Slot() + def poll_std_file_change(self): + """Check if the stderr or stdout file just changed.""" + self.shellwidget.call_kernel().flush_std() + starting = self.shellwidget._starting + if self.stderr_obj is not None: + stderr = self.stderr_obj.poll_file_change() + if stderr: + if self.is_benign_error(stderr): + return + if self.shellwidget.isHidden(): + # Avoid printing the same thing again + if self.error_text != '%s' % stderr: + full_stderr = self.stderr_obj.get_contents() + self.show_kernel_error('%s' % full_stderr) + if starting: + self.shellwidget.banner = ( + stderr + '\n' + self.shellwidget.banner) + else: + self.shellwidget._append_plain_text( + '\n' + stderr, before_prompt=True) + + if self.stdout_obj is not None: + stdout = self.stdout_obj.poll_file_change() + if stdout: + if starting: + self.shellwidget.banner = ( + stdout + '\n' + self.shellwidget.banner) + else: + self.shellwidget._append_plain_text( + '\n' + stdout, before_prompt=True) + + def configure_shellwidget(self, give_focus=True): + """Configure shellwidget after kernel is connected.""" + self.give_focus = give_focus + + # Make sure the kernel sends the comm config over + self.shellwidget.call_kernel()._send_comm_config() + + # Set exit callback + self.shellwidget.set_exit_callback() + + # To save history + self.shellwidget.executing.connect(self.add_to_history) + + # For Mayavi to run correctly + self.shellwidget.executing.connect( + self.shellwidget.set_backend_for_mayavi) + + # To update history after execution + self.shellwidget.executed.connect(self.update_history) + + # To update the Variable Explorer after execution + self.shellwidget.executed.connect( + self.shellwidget.refresh_namespacebrowser) + + # To enable the stop button when executing a process + self.shellwidget.executing.connect( + self.sig_execution_state_changed) + + # To disable the stop button after execution stopped + self.shellwidget.executed.connect( + self.sig_execution_state_changed) + + # To show kernel restarted/died messages + self.shellwidget.sig_kernel_restarted_message.connect( + self.kernel_restarted_message) + self.shellwidget.sig_kernel_restarted.connect( + self._finalise_restart) + + # To correctly change Matplotlib backend interactively + self.shellwidget.executing.connect( + self.shellwidget.change_mpl_backend) + + # To show env and sys.path contents + self.shellwidget.sig_show_syspath.connect(self.show_syspath) + self.shellwidget.sig_show_env.connect(self.show_env) + + # To sync with working directory toolbar + self.shellwidget.executed.connect(self.shellwidget.update_cwd) + + # To apply style + self.set_color_scheme(self.shellwidget.syntax_style, reset=False) + + if self.fault_obj is not None: + # To display faulthandler + self.shellwidget.call_kernel().enable_faulthandler( + self.fault_obj.filename) + + def add_to_history(self, command): + """Add command to history""" + if self.shellwidget.is_debugging(): + return + return super(ClientWidget, self).add_to_history(command) + + def is_client_executing(self): + return (self.shellwidget._executing or + self.shellwidget.is_waiting_pdb_input()) + + @Slot() + def stop_button_click_handler(self): + """Method to handle what to do when the stop button is pressed""" + # Interrupt computations or stop debugging + if not self.shellwidget.is_waiting_pdb_input(): + self.interrupt_kernel() + else: + self.shellwidget.pdb_execute_command('exit') + + def show_kernel_error(self, error): + """Show kernel initialization errors in infowidget.""" + self.error_text = error + + if self.is_benign_error(error): + return + + InstallerIPythonKernelError(error) + + # Replace end of line chars with
+ eol = sourcecode.get_eol_chars(error) + if eol: + error = error.replace(eol, '
') + + # Don't break lines in hyphens + # From https://stackoverflow.com/q/7691569/438386 + error = error.replace('-', '‑') + + # Create error page + message = _("An error ocurred while starting the kernel") + kernel_error_template = Template(KERNEL_ERROR) + self.info_page = kernel_error_template.substitute( + css_path=self.css_path, + message=message, + error=error) + + # Show error + if self.infowidget is not None: + self.set_info_page() + self.shellwidget.hide() + self.infowidget.show() + + # Tell the client we're in error mode + self.is_error_shown = True + + # Stop shellwidget + self.shellwidget.shutdown() + self.remove_std_files(is_last_client=False) + + def is_benign_error(self, error): + """Decide if an error is benign in order to filter it.""" + benign_errors = [ + # Error when switching from the Qt5 backend to the Tk one. + # See spyder-ide/spyder#17488 + "KeyboardInterrupt caught in kernel", + "QSocketNotifier: Multiple socket notifiers for same socket", + # Error when switching from the Tk backend to the Qt5 one. + # See spyder-ide/spyder#17488 + "Tcl_AsyncDelete async handler deleted by the wrong thread", + "error in background error handler:", + " while executing", + '"::tcl::Bgerror', + # Avoid showing this warning because it was up to the user to + # disable secure writes. + "WARNING: Insecure writes have been enabled via environment", + # Old error + "No such comm" + ] + + return any([err in error for err in benign_errors]) + + def get_name(self): + """Return client name""" + if self.given_name is None: + # Name according to host + if self.hostname is None: + name = _("Console") + else: + name = self.hostname + # Adding id to name + client_id = self.id_['int_id'] + u'/' + self.id_['str_id'] + name = name + u' ' + client_id + elif self.given_name in ["Pylab", "SymPy", "Cython"]: + client_id = self.id_['int_id'] + u'/' + self.id_['str_id'] + name = self.given_name + u' ' + client_id + else: + name = self.given_name + u'/' + self.id_['str_id'] + return name + + def get_control(self): + """Return the text widget (or similar) to give focus to""" + # page_control is the widget used for paging + page_control = self.shellwidget._page_control + if page_control and page_control.isVisible(): + return page_control + else: + return self.shellwidget._control + + def get_kernel(self): + """Get kernel associated with this client""" + return self.shellwidget.kernel_manager + + def add_actions_to_context_menu(self, menu): + """Add actions to IPython widget context menu""" + add_actions(menu, self.context_menu_actions) + + return menu + + def set_font(self, font): + """Set IPython widget's font""" + self.shellwidget._control.setFont(font) + self.shellwidget.font = font + + def set_color_scheme(self, color_scheme, reset=True): + """Set IPython color scheme.""" + # Needed to handle not initialized kernel_client + # See spyder-ide/spyder#6996. + try: + self.shellwidget.set_color_scheme(color_scheme, reset) + except AttributeError: + pass + + def shutdown(self, is_last_client): + """Shutdown connection and kernel if needed.""" + self.dialog_manager.close_all() + if (self.restart_thread is not None + and self.restart_thread.isRunning()): + self.restart_thread.finished.disconnect() + self.restart_thread.quit() + self.restart_thread.wait() + shutdown_kernel = ( + is_last_client and not self.is_external_kernel + and not self.is_error_shown) + self.shellwidget.shutdown(shutdown_kernel) + self.remove_std_files(shutdown_kernel) + + def interrupt_kernel(self): + """Interrupt the associanted Spyder kernel if it's running""" + # Needed to prevent a crash when a kernel is not running. + # See spyder-ide/spyder#6299. + try: + self.shellwidget.request_interrupt_kernel() + except RuntimeError: + pass + + @Slot() + def restart_kernel(self): + """ + Restart the associated kernel. + + Took this code from the qtconsole project + Licensed under the BSD license + """ + sw = self.shellwidget + + if not running_under_pytest() and self.ask_before_restart: + message = _('Are you sure you want to restart the kernel?') + buttons = QMessageBox.Yes | QMessageBox.No + result = QMessageBox.question(self, _('Restart kernel?'), + message, buttons) + else: + result = None + + if (result == QMessageBox.Yes or + running_under_pytest() or + not self.ask_before_restart): + if sw.kernel_manager: + if self.infowidget is not None: + if self.infowidget.isVisible(): + self.infowidget.hide() + + if self._abort_kernel_restart(): + sw.spyder_kernel_comm.close() + return + + self._show_loading_page() + + # Close comm + sw.spyder_kernel_comm.close() + + # Stop autorestart mechanism + sw.kernel_manager.stop_restarter() + sw.kernel_manager.autorestart = False + + # Reconfigure client before the new kernel is connected again. + self._before_prompt_is_ready(show_loading_page=False) + + # Create and run restarting thread + if (self.restart_thread is not None + and self.restart_thread.isRunning()): + self.restart_thread.finished.disconnect() + self.restart_thread.quit() + self.restart_thread.wait() + self.restart_thread = QThread(None) + self.restart_thread.run = self._restart_thread_main + self.restart_thread.error = None + self.restart_thread.finished.connect( + lambda: self._finalise_restart(True)) + self.restart_thread.start() + + else: + sw._append_plain_text( + _('Cannot restart a kernel not started by Spyder\n'), + before_prompt=True + ) + self._hide_loading_page() + + def _restart_thread_main(self): + """Restart the kernel in a thread.""" + try: + self.shellwidget.kernel_manager.restart_kernel( + stderr=self.stderr_obj.handle, + stdout=self.stdout_obj.handle) + except RuntimeError as e: + self.restart_thread.error = e + + def _finalise_restart(self, reset=False): + """Finishes the restarting of the kernel.""" + sw = self.shellwidget + + if self._abort_kernel_restart(): + sw.spyder_kernel_comm.close() + return + + if self.restart_thread and self.restart_thread.error is not None: + sw._append_plain_text( + _('Error restarting kernel: %s\n') % self.restart_thread.error, + before_prompt=True + ) + else: + if self.fault_obj is not None: + fault = self.fault_obj.get_contents() + if fault: + fault = self.filter_fault(fault) + self.shellwidget._append_plain_text( + '\n' + fault, before_prompt=True) + + # Reset Pdb state and reopen comm + sw._pdb_in_loop = False + sw.spyder_kernel_comm.remove() + try: + sw.spyder_kernel_comm.open_comm(sw.kernel_client) + except AttributeError: + # An error occurred while opening our comm channel. + # Aborting! + return + + # Start autorestart mechanism + sw.kernel_manager.autorestart = True + sw.kernel_manager.start_restarter() + + # For spyder-ide/spyder#6235, IPython was changing the + # setting of %colors on windows by assuming it was using a + # dark background. This corrects it based on the scheme. + self.set_color_scheme(sw.syntax_style, reset=reset) + sw._append_html(_("
Restarting kernel...
"), + before_prompt=True) + sw.insert_horizontal_ruler() + if self.fault_obj is not None: + self.shellwidget.call_kernel().enable_faulthandler( + self.fault_obj.filename) + + self._hide_loading_page() + self.restart_thread = None + self.sig_execution_state_changed.emit() + + def filter_fault(self, fault): + """Get a fault from a previous session.""" + thread_regex = ( + r"(Current thread|Thread) " + r"(0x[\da-f]+) \(most recent call first\):" + r"(?:.|\r\n|\r|\n)+?(?=Current thread|Thread|\Z)") + # Keep line for future improvments + # files_regex = r"File \"([^\"]+)\", line (\d+) in (\S+)" + + main_re = "Main thread id:(?:\r\n|\r|\n)(0x[0-9a-f]+)" + main_id = 0 + for match in re.finditer(main_re, fault): + main_id = int(match.group(1), base=16) + + system_re = ("System threads ids:" + "(?:\r\n|\r|\n)(0x[0-9a-f]+(?: 0x[0-9a-f]+)+)") + ignore_ids = [] + start_idx = 0 + for match in re.finditer(system_re, fault): + ignore_ids = [int(i, base=16) for i in match.group(1).split()] + start_idx = match.span()[1] + text = "" + for idx, match in enumerate(re.finditer(thread_regex, fault)): + if idx == 0: + text += fault[start_idx:match.span()[0]] + thread_id = int(match.group(2), base=16) + if thread_id != main_id: + if thread_id in ignore_ids: + continue + if "wurlitzer.py" in match.group(0): + # Wurlitzer threads are launched later + continue + text += "\n" + match.group(0) + "\n" + else: + try: + pattern = (r".*(?:/IPython/core/interactiveshell\.py|" + r"\\IPython\\core\\interactiveshell\.py).*") + match_internal = next(re.finditer(pattern, match.group(0))) + end_idx = match_internal.span()[0] + except StopIteration: + end_idx = None + text += "\nMain thread:\n" + match.group(0)[:end_idx] + "\n" + return text + + @Slot(str) + def kernel_restarted_message(self, msg): + """Show kernel restarted/died messages.""" + if self.stderr_obj is not None: + # If there are kernel creation errors, jupyter_client will + # try to restart the kernel and qtconsole prints a + # message about it. + # So we read the kernel's stderr_file and display its + # contents in the client instead of the usual message shown + # by qtconsole. + self.poll_std_file_change() + else: + self.shellwidget._append_html("
%s

" % msg, + before_prompt=False) + + @Slot() + def enter_array_inline(self): + """Enter and show the array builder on inline mode.""" + self.shellwidget._control.enter_array_inline() + + @Slot() + def enter_array_table(self): + """Enter and show the array builder on table.""" + self.shellwidget._control.enter_array_table() + + @Slot() + def inspect_object(self): + """Show how to inspect an object with our Help plugin""" + self.shellwidget._control.inspect_current_object() + + @Slot() + def clear_line(self): + """Clear a console line""" + self.shellwidget._keyboard_quit() + + @Slot() + def clear_console(self): + """Clear the whole console""" + self.shellwidget.clear_console() + + @Slot() + def reset_namespace(self): + """Resets the namespace by removing all names defined by the user""" + self.shellwidget.reset_namespace(warning=self.reset_warning, + message=True) + + def update_history(self): + self.history = self.shellwidget._history + + @Slot(object) + def show_syspath(self, syspath): + """Show sys.path contents.""" + if syspath is not None: + editor = CollectionsEditor(self) + editor.setup(syspath, title="sys.path contents", readonly=True, + icon=ima.icon('syspath')) + self.dialog_manager.show(editor) + else: + return + + @Slot(object) + def show_env(self, env): + """Show environment variables.""" + self.dialog_manager.show(RemoteEnvDialog(env, parent=self)) + + def show_time(self, end=False): + """Text to show in time_label.""" + if self.time_label is None: + return + + elapsed_time = time.monotonic() - self.t0 + # System time changed to past date, so reset start. + if elapsed_time < 0: + self.t0 = time.monotonic() + elapsed_time = 0 + if elapsed_time > 24 * 3600: # More than a day...! + fmt = "%d %H:%M:%S" + else: + fmt = "%H:%M:%S" + if end: + color = QStylePalette.COLOR_TEXT_3 + else: + color = QStylePalette.COLOR_ACCENT_4 + text = "%s" \ + "" % (color, + time.strftime(fmt, time.gmtime(elapsed_time))) + if self.show_elapsed_time: + self.time_label.setText(text) + else: + self.time_label.setText("") + + @Slot(bool) + def set_show_elapsed_time(self, state): + """Slot to show/hide elapsed time label.""" + self.show_elapsed_time = state + + def set_info_page(self): + """Set current info_page.""" + if self.infowidget is not None and self.info_page is not None: + self.infowidget.setHtml( + self.info_page, + QUrl.fromLocalFile(self.css_path) + ) diff --git a/spyder/plugins/layout/container.py b/spyder/plugins/layout/container.py index 18b2c6f47b4..bb36e742268 100644 --- a/spyder/plugins/layout/container.py +++ b/spyder/plugins/layout/container.py @@ -1,440 +1,440 @@ -# -*- coding: utf-8 -*- -# -# Copyright © Spyder Project Contributors -# Licensed under the terms of the MIT License -# (see spyder/__init__.py for details) - -""" -Layout container. -""" - -# Standard library imports -from collections import OrderedDict -import sys - -# Third party imports -from qtpy.QtCore import Qt, Slot -from qtpy.QtWidgets import QMessageBox - -# Local imports -from spyder.api.exceptions import SpyderAPIError -from spyder.api.translations import get_translation -from spyder.api.widgets.main_container import PluginMainContainer -from spyder.plugins.layout.api import BaseGridLayoutType -from spyder.plugins.layout.layouts import DefaultLayouts -from spyder.plugins.layout.widgets.dialog import ( - LayoutSaveDialog, LayoutSettingsDialog) - -# Localization -_ = get_translation("spyder") - - -class LayoutContainerActions: - DefaultLayout = 'default_layout_action' - MatlabLayout = 'matlab_layout_action' - RStudio = 'rstudio_layout_action' - HorizontalSplit = 'horizontal_split_layout_action' - VerticalSplit = 'vertical_split_layout_action' - SaveLayoutAction = 'save_layout_action' - ShowLayoutPreferencesAction = 'show_layout_preferences_action' - ResetLayout = 'reset_layout_action' - # Needs to have 'Maximize pane' as name to properly register - # the action shortcut - MaximizeCurrentDockwidget = 'Maximize pane' - # Needs to have 'Fullscreen mode' as name to properly register - # the action shortcut - Fullscreen = 'Fullscreen mode' - # Needs to have 'Use next layout' as name to properly register - # the action shortcut - NextLayout = 'Use next layout' - # Needs to have 'Use previous layout' as name to properly register - # the action shortcut - PreviousLayout = 'Use previous layout' - # Needs to have 'Close pane' as name to properly register - # the action shortcut - CloseCurrentDockwidget = 'Close pane' - # Needs to have 'Lock unlock panes' as name to properly register - # the action shortcut - LockDockwidgetsAndToolbars = 'Lock unlock panes' - - -class LayoutPluginMenus: - PluginsMenu = "plugins_menu" - LayoutsMenu = 'layouts_menu' - - -class LayoutContainer(PluginMainContainer): - """ - Plugin container class that handles the Spyder quick layouts functionality. - """ - - def setup(self): - # Basic attributes to handle layouts options and dialogs references - self._spyder_layouts = OrderedDict() - self._save_dialog = None - self._settings_dialog = None - self._layouts_menu = None - self._current_quick_layout = None - - # Close current dockable plugin - self._close_dockwidget_action = self.create_action( - LayoutContainerActions.CloseCurrentDockwidget, - text=_('Close current pane'), - icon=self.create_icon('close_pane'), - triggered=self._plugin.close_current_dockwidget, - context=Qt.ApplicationShortcut, - register_shortcut=True, - shortcut_context='_' - ) - - # Maximize current dockable plugin - self._maximize_dockwidget_action = self.create_action( - LayoutContainerActions.MaximizeCurrentDockwidget, - text=_('Maximize current pane'), - icon=self.create_icon('maximize'), - toggled=lambda state: self._plugin.maximize_dockwidget(), - context=Qt.ApplicationShortcut, - register_shortcut=True, - shortcut_context='_') - - # Fullscreen mode - self._fullscreen_action = self.create_action( - LayoutContainerActions.Fullscreen, - text=_('Fullscreen mode'), - triggered=self._plugin.toggle_fullscreen, - context=Qt.ApplicationShortcut, - register_shortcut=True, - shortcut_context='_') - if sys.platform == 'darwin': - self._fullscreen_action.setEnabled(False) - self._fullscreen_action.setToolTip(_("For fullscreen mode use the " - "macOS built-in feature")) - - # Lock dockwidgets and toolbars - self._lock_interface_action = self.create_action( - LayoutContainerActions.LockDockwidgetsAndToolbars, - text='', - triggered=lambda checked: - self._plugin.toggle_lock(), - context=Qt.ApplicationShortcut, - register_shortcut=True, - shortcut_context='_' - ) - - self._save_layout_action = self.create_action( - LayoutContainerActions.SaveLayoutAction, - _("Save current layout"), - triggered=lambda: self.show_save_layout(), - context=Qt.ApplicationShortcut, - register_shortcut=False, - ) - self._show_preferences_action = self.create_action( - LayoutContainerActions.ShowLayoutPreferencesAction, - text=_("Layout preferences"), - triggered=lambda: self.show_layout_settings(), - context=Qt.ApplicationShortcut, - register_shortcut=False, - ) - self._reset_action = self.create_action( - LayoutContainerActions.ResetLayout, - text=_('Reset to Spyder default'), - triggered=self.reset_window_layout, - register_shortcut=False, - ) - - # Layouts shortcuts actions - self._toggle_next_layout_action = self.create_action( - LayoutContainerActions.NextLayout, - _("Use next layout"), - triggered=self.toggle_next_layout, - context=Qt.ApplicationShortcut, - register_shortcut=True, - shortcut_context='_') - self._toggle_previous_layout_action = self.create_action( - LayoutContainerActions.PreviousLayout, - _("Use previous layout"), - triggered=self.toggle_previous_layout, - context=Qt.ApplicationShortcut, - register_shortcut=True, - shortcut_context='_') - - # Layouts menu - self._layouts_menu = self.create_menu( - LayoutPluginMenus.LayoutsMenu, _("Window layouts")) - - self._plugins_menu = self.create_menu( - LayoutPluginMenus.PluginsMenu, _("Panes")) - self._plugins_menu.setObjectName('checkbox-padding') - - def update_actions(self): - pass - - def update_layout_menu_actions(self): - """ - Update layouts menu and layouts related actions. - """ - menu = self._layouts_menu - menu.clear_actions() - names = self.get_conf('names') - ui_names = self.get_conf('ui_names') - order = self.get_conf('order') - active = self.get_conf('active') - - actions = [] - for name in order: - if name in active: - if name in self._spyder_layouts: - index = name - name = self._spyder_layouts[index].get_name() - else: - index = names.index(name) - name = ui_names[index] - - # closure required so lambda works with the default parameter - def trigger(i=index, self=self): - return lambda: self.quick_layout_switch(i) - - layout_switch_action = self.create_action( - name, - text=name, - triggered=trigger(), - register_shortcut=False, - overwrite=True - ) - - actions.append(layout_switch_action) - - for item in actions: - self.add_item_to_menu(item, menu, section="layouts_section") - - for item in [self._save_layout_action, self._show_preferences_action, - self._reset_action]: - self.add_item_to_menu(item, menu, section="layouts_section_2") - - self._show_preferences_action.setEnabled(len(order) != 0) - - # --- Public API - # ------------------------------------------------------------------------ - def critical_message(self, title, message): - """Expose a QMessageBox.critical dialog to be used from the plugin.""" - QMessageBox.critical(self, title, message) - - def register_layout(self, parent_plugin, layout_type): - """ - Register a new layout type. - - Parameters - ---------- - parent_plugin: spyder.api.plugins.SpyderPluginV2 - Plugin registering the layout type. - layout_type: spyder.plugins.layout.api.BaseGridLayoutType - Layout to register. - """ - if not issubclass(layout_type, BaseGridLayoutType): - raise SpyderAPIError( - "A layout must be a subclass is `BaseGridLayoutType`!") - - layout_id = layout_type.ID - if layout_id in self._spyder_layouts: - raise SpyderAPIError( - "Layout with id `{}` already registered!".format(layout_id)) - - layout = layout_type(parent_plugin) - layout._check_layout_validity() - self._spyder_layouts[layout_id] = layout - names = self.get_conf('names') - ui_names = self.get_conf('ui_names') - order = self.get_conf('order') - active = self.get_conf('active') - - if layout_id not in names: - names.append(layout_id) - ui_names.append(layout.get_name()) - order.append(layout_id) - active.append(layout_id) - self.set_conf('names', names) - self.set_conf('ui_names', ui_names) - self.set_conf('order', order) - self.set_conf('active', active) - - def get_layout(self, layout_id): - """ - Get a registered layout by its ID. - - Parameters - ---------- - layout_id : string - The ID of the layout. - - Raises - ------ - SpyderAPIError - If the given id is not found in the registered layouts. - - Returns - ------- - Instance of a spyder.plugins.layout.api.BaseGridLayoutType subclass - Layout. - """ - if layout_id not in self._spyder_layouts: - raise SpyderAPIError( - "Layout with id `{}` is not registered!".format(layout_id)) - - return self._spyder_layouts[layout_id] - - def show_save_layout(self): - """Show the save layout dialog.""" - names = self.get_conf('names') - ui_names = self.get_conf('ui_names') - order = self.get_conf('order') - active = self.get_conf('active') - dialog_names = [name for name in names - if name not in self._spyder_layouts.keys()] - dlg = self._save_dialog = LayoutSaveDialog(self, dialog_names) - - if dlg.exec_(): - name = dlg.combo_box.currentText() - if name in self._spyder_layouts: - QMessageBox.critical( - self, - _("Error"), - _("Layout {0} was defined programatically. " - "It is not possible to overwrite programatically " - "registered layouts.").format(name) - ) - return - if name in names: - answer = QMessageBox.warning( - self, - _("Warning"), - _("Layout {0} will be overwritten. " - "Do you want to continue?").format(name), - QMessageBox.Yes | QMessageBox.No, - ) - index = order.index(name) - else: - answer = True - if None in names: - index = names.index(None) - names[index] = name - else: - index = len(names) - names.append(name) - - order.append(name) - - # Always make active a new layout even if it overwrites an - # inactive layout - if name not in active: - active.append(name) - - if name not in ui_names: - ui_names.append(name) - - if answer: - self._plugin.save_current_window_settings( - 'layout_{}/'.format(index), section='quick_layouts') - self.set_conf('names', names) - self.set_conf('ui_names', ui_names) - self.set_conf('order', order) - self.set_conf('active', active) - - self.update_layout_menu_actions() - - def show_layout_settings(self): - """Layout settings dialog.""" - names = self.get_conf('names') - ui_names = self.get_conf('ui_names') - order = self.get_conf('order') - active = self.get_conf('active') - read_only = list(self._spyder_layouts.keys()) - - dlg = self._settings_dialog = LayoutSettingsDialog( - self, names, ui_names, order, active, read_only) - if dlg.exec_(): - self.set_conf('names', dlg.names) - self.set_conf('ui_names', dlg.ui_names) - self.set_conf('order', dlg.order) - self.set_conf('active', dlg.active) - - self.update_layout_menu_actions() - - @Slot() - def reset_window_layout(self): - """Reset window layout to default.""" - answer = QMessageBox.warning( - self, - _("Warning"), - _("Window layout will be reset to default settings: " - "this affects window position, size and dockwidgets.\n" - "Do you want to continue?"), - QMessageBox.Yes | QMessageBox.No, - ) - - if answer == QMessageBox.Yes: - self._plugin.setup_layout(default=True) - - @Slot() - def toggle_previous_layout(self): - """Use the previous layout from the layouts list (default + custom).""" - self.toggle_layout('previous') - - @Slot() - def toggle_next_layout(self): - """Use the next layout from the layouts list (default + custom).""" - self.toggle_layout('next') - - def toggle_layout(self, direction='next'): - """Change current layout.""" - names = self.get_conf('names') - order = self.get_conf('order') - active = self.get_conf('active') - - if len(active) == 0: - return - - layout_index = [] - for name in order: - if name in active: - layout_index.append(names.index(name)) - - current_layout = self._current_quick_layout - dic = {'next': 1, 'previous': -1} - - if current_layout is None: - # Start from default - current_layout = names.index(DefaultLayouts.SpyderLayout) - - if current_layout in layout_index: - current_index = layout_index.index(current_layout) - else: - current_index = 0 - - new_index = (current_index + dic[direction]) % len(layout_index) - index_or_layout_id = layout_index[new_index] - is_layout_id = ( - names[index_or_layout_id] in self._spyder_layouts) - - if is_layout_id: - index_or_layout_id = names[layout_index[new_index]] - - self.quick_layout_switch(index_or_layout_id) - - def quick_layout_switch(self, index_or_layout_id): - """ - Switch to quick layout number *index* or *layout id*. - - Parameters - ---------- - index: int or str - """ - possible_current_layout = self._plugin.quick_layout_switch( - index_or_layout_id) - - if possible_current_layout is not None: - if isinstance(possible_current_layout, int): - self._current_quick_layout = possible_current_layout - else: - names = self.get_conf('names') - self._current_quick_layout = names.index( - possible_current_layout) +# -*- coding: utf-8 -*- +# +# Copyright © Spyder Project Contributors +# Licensed under the terms of the MIT License +# (see spyder/__init__.py for details) + +""" +Layout container. +""" + +# Standard library imports +from collections import OrderedDict +import sys + +# Third party imports +from qtpy.QtCore import Qt, Slot +from qtpy.QtWidgets import QMessageBox + +# Local imports +from spyder.api.exceptions import SpyderAPIError +from spyder.api.translations import get_translation +from spyder.api.widgets.main_container import PluginMainContainer +from spyder.plugins.layout.api import BaseGridLayoutType +from spyder.plugins.layout.layouts import DefaultLayouts +from spyder.plugins.layout.widgets.dialog import ( + LayoutSaveDialog, LayoutSettingsDialog) + +# Localization +_ = get_translation("spyder") + + +class LayoutContainerActions: + DefaultLayout = 'default_layout_action' + MatlabLayout = 'matlab_layout_action' + RStudio = 'rstudio_layout_action' + HorizontalSplit = 'horizontal_split_layout_action' + VerticalSplit = 'vertical_split_layout_action' + SaveLayoutAction = 'save_layout_action' + ShowLayoutPreferencesAction = 'show_layout_preferences_action' + ResetLayout = 'reset_layout_action' + # Needs to have 'Maximize pane' as name to properly register + # the action shortcut + MaximizeCurrentDockwidget = 'Maximize pane' + # Needs to have 'Fullscreen mode' as name to properly register + # the action shortcut + Fullscreen = 'Fullscreen mode' + # Needs to have 'Use next layout' as name to properly register + # the action shortcut + NextLayout = 'Use next layout' + # Needs to have 'Use previous layout' as name to properly register + # the action shortcut + PreviousLayout = 'Use previous layout' + # Needs to have 'Close pane' as name to properly register + # the action shortcut + CloseCurrentDockwidget = 'Close pane' + # Needs to have 'Lock unlock panes' as name to properly register + # the action shortcut + LockDockwidgetsAndToolbars = 'Lock unlock panes' + + +class LayoutPluginMenus: + PluginsMenu = "plugins_menu" + LayoutsMenu = 'layouts_menu' + + +class LayoutContainer(PluginMainContainer): + """ + Plugin container class that handles the Spyder quick layouts functionality. + """ + + def setup(self): + # Basic attributes to handle layouts options and dialogs references + self._spyder_layouts = OrderedDict() + self._save_dialog = None + self._settings_dialog = None + self._layouts_menu = None + self._current_quick_layout = None + + # Close current dockable plugin + self._close_dockwidget_action = self.create_action( + LayoutContainerActions.CloseCurrentDockwidget, + text=_('Close current pane'), + icon=self.create_icon('close_pane'), + triggered=self._plugin.close_current_dockwidget, + context=Qt.ApplicationShortcut, + register_shortcut=True, + shortcut_context='_' + ) + + # Maximize current dockable plugin + self._maximize_dockwidget_action = self.create_action( + LayoutContainerActions.MaximizeCurrentDockwidget, + text=_('Maximize current pane'), + icon=self.create_icon('maximize'), + toggled=lambda state: self._plugin.maximize_dockwidget(), + context=Qt.ApplicationShortcut, + register_shortcut=True, + shortcut_context='_') + + # Fullscreen mode + self._fullscreen_action = self.create_action( + LayoutContainerActions.Fullscreen, + text=_('Fullscreen mode'), + triggered=self._plugin.toggle_fullscreen, + context=Qt.ApplicationShortcut, + register_shortcut=True, + shortcut_context='_') + if sys.platform == 'darwin': + self._fullscreen_action.setEnabled(False) + self._fullscreen_action.setToolTip(_("For fullscreen mode use the " + "macOS built-in feature")) + + # Lock dockwidgets and toolbars + self._lock_interface_action = self.create_action( + LayoutContainerActions.LockDockwidgetsAndToolbars, + text='', + triggered=lambda checked: + self._plugin.toggle_lock(), + context=Qt.ApplicationShortcut, + register_shortcut=True, + shortcut_context='_' + ) + + self._save_layout_action = self.create_action( + LayoutContainerActions.SaveLayoutAction, + _("Save current layout"), + triggered=lambda: self.show_save_layout(), + context=Qt.ApplicationShortcut, + register_shortcut=False, + ) + self._show_preferences_action = self.create_action( + LayoutContainerActions.ShowLayoutPreferencesAction, + text=_("Layout preferences"), + triggered=lambda: self.show_layout_settings(), + context=Qt.ApplicationShortcut, + register_shortcut=False, + ) + self._reset_action = self.create_action( + LayoutContainerActions.ResetLayout, + text=_('Reset to Spyder default'), + triggered=self.reset_window_layout, + register_shortcut=False, + ) + + # Layouts shortcuts actions + self._toggle_next_layout_action = self.create_action( + LayoutContainerActions.NextLayout, + _("Use next layout"), + triggered=self.toggle_next_layout, + context=Qt.ApplicationShortcut, + register_shortcut=True, + shortcut_context='_') + self._toggle_previous_layout_action = self.create_action( + LayoutContainerActions.PreviousLayout, + _("Use previous layout"), + triggered=self.toggle_previous_layout, + context=Qt.ApplicationShortcut, + register_shortcut=True, + shortcut_context='_') + + # Layouts menu + self._layouts_menu = self.create_menu( + LayoutPluginMenus.LayoutsMenu, _("Window layouts")) + + self._plugins_menu = self.create_menu( + LayoutPluginMenus.PluginsMenu, _("Panes")) + self._plugins_menu.setObjectName('checkbox-padding') + + def update_actions(self): + pass + + def update_layout_menu_actions(self): + """ + Update layouts menu and layouts related actions. + """ + menu = self._layouts_menu + menu.clear_actions() + names = self.get_conf('names') + ui_names = self.get_conf('ui_names') + order = self.get_conf('order') + active = self.get_conf('active') + + actions = [] + for name in order: + if name in active: + if name in self._spyder_layouts: + index = name + name = self._spyder_layouts[index].get_name() + else: + index = names.index(name) + name = ui_names[index] + + # closure required so lambda works with the default parameter + def trigger(i=index, self=self): + return lambda: self.quick_layout_switch(i) + + layout_switch_action = self.create_action( + name, + text=name, + triggered=trigger(), + register_shortcut=False, + overwrite=True + ) + + actions.append(layout_switch_action) + + for item in actions: + self.add_item_to_menu(item, menu, section="layouts_section") + + for item in [self._save_layout_action, self._show_preferences_action, + self._reset_action]: + self.add_item_to_menu(item, menu, section="layouts_section_2") + + self._show_preferences_action.setEnabled(len(order) != 0) + + # --- Public API + # ------------------------------------------------------------------------ + def critical_message(self, title, message): + """Expose a QMessageBox.critical dialog to be used from the plugin.""" + QMessageBox.critical(self, title, message) + + def register_layout(self, parent_plugin, layout_type): + """ + Register a new layout type. + + Parameters + ---------- + parent_plugin: spyder.api.plugins.SpyderPluginV2 + Plugin registering the layout type. + layout_type: spyder.plugins.layout.api.BaseGridLayoutType + Layout to register. + """ + if not issubclass(layout_type, BaseGridLayoutType): + raise SpyderAPIError( + "A layout must be a subclass is `BaseGridLayoutType`!") + + layout_id = layout_type.ID + if layout_id in self._spyder_layouts: + raise SpyderAPIError( + "Layout with id `{}` already registered!".format(layout_id)) + + layout = layout_type(parent_plugin) + layout._check_layout_validity() + self._spyder_layouts[layout_id] = layout + names = self.get_conf('names') + ui_names = self.get_conf('ui_names') + order = self.get_conf('order') + active = self.get_conf('active') + + if layout_id not in names: + names.append(layout_id) + ui_names.append(layout.get_name()) + order.append(layout_id) + active.append(layout_id) + self.set_conf('names', names) + self.set_conf('ui_names', ui_names) + self.set_conf('order', order) + self.set_conf('active', active) + + def get_layout(self, layout_id): + """ + Get a registered layout by its ID. + + Parameters + ---------- + layout_id : string + The ID of the layout. + + Raises + ------ + SpyderAPIError + If the given id is not found in the registered layouts. + + Returns + ------- + Instance of a spyder.plugins.layout.api.BaseGridLayoutType subclass + Layout. + """ + if layout_id not in self._spyder_layouts: + raise SpyderAPIError( + "Layout with id `{}` is not registered!".format(layout_id)) + + return self._spyder_layouts[layout_id] + + def show_save_layout(self): + """Show the save layout dialog.""" + names = self.get_conf('names') + ui_names = self.get_conf('ui_names') + order = self.get_conf('order') + active = self.get_conf('active') + dialog_names = [name for name in names + if name not in self._spyder_layouts.keys()] + dlg = self._save_dialog = LayoutSaveDialog(self, dialog_names) + + if dlg.exec_(): + name = dlg.combo_box.currentText() + if name in self._spyder_layouts: + QMessageBox.critical( + self, + _("Error"), + _("Layout {0} was defined programatically. " + "It is not possible to overwrite programatically " + "registered layouts.").format(name) + ) + return + if name in names: + answer = QMessageBox.warning( + self, + _("Warning"), + _("Layout {0} will be overwritten. " + "Do you want to continue?").format(name), + QMessageBox.Yes | QMessageBox.No, + ) + index = order.index(name) + else: + answer = True + if None in names: + index = names.index(None) + names[index] = name + else: + index = len(names) + names.append(name) + + order.append(name) + + # Always make active a new layout even if it overwrites an + # inactive layout + if name not in active: + active.append(name) + + if name not in ui_names: + ui_names.append(name) + + if answer: + self._plugin.save_current_window_settings( + 'layout_{}/'.format(index), section='quick_layouts') + self.set_conf('names', names) + self.set_conf('ui_names', ui_names) + self.set_conf('order', order) + self.set_conf('active', active) + + self.update_layout_menu_actions() + + def show_layout_settings(self): + """Layout settings dialog.""" + names = self.get_conf('names') + ui_names = self.get_conf('ui_names') + order = self.get_conf('order') + active = self.get_conf('active') + read_only = list(self._spyder_layouts.keys()) + + dlg = self._settings_dialog = LayoutSettingsDialog( + self, names, ui_names, order, active, read_only) + if dlg.exec_(): + self.set_conf('names', dlg.names) + self.set_conf('ui_names', dlg.ui_names) + self.set_conf('order', dlg.order) + self.set_conf('active', dlg.active) + + self.update_layout_menu_actions() + + @Slot() + def reset_window_layout(self): + """Reset window layout to default.""" + answer = QMessageBox.warning( + self, + _("Warning"), + _("Window layout will be reset to default settings: " + "this affects window position, size and dockwidgets.\n" + "Do you want to continue?"), + QMessageBox.Yes | QMessageBox.No, + ) + + if answer == QMessageBox.Yes: + self._plugin.setup_layout(default=True) + + @Slot() + def toggle_previous_layout(self): + """Use the previous layout from the layouts list (default + custom).""" + self.toggle_layout('previous') + + @Slot() + def toggle_next_layout(self): + """Use the next layout from the layouts list (default + custom).""" + self.toggle_layout('next') + + def toggle_layout(self, direction='next'): + """Change current layout.""" + names = self.get_conf('names') + order = self.get_conf('order') + active = self.get_conf('active') + + if len(active) == 0: + return + + layout_index = [] + for name in order: + if name in active: + layout_index.append(names.index(name)) + + current_layout = self._current_quick_layout + dic = {'next': 1, 'previous': -1} + + if current_layout is None: + # Start from default + current_layout = names.index(DefaultLayouts.SpyderLayout) + + if current_layout in layout_index: + current_index = layout_index.index(current_layout) + else: + current_index = 0 + + new_index = (current_index + dic[direction]) % len(layout_index) + index_or_layout_id = layout_index[new_index] + is_layout_id = ( + names[index_or_layout_id] in self._spyder_layouts) + + if is_layout_id: + index_or_layout_id = names[layout_index[new_index]] + + self.quick_layout_switch(index_or_layout_id) + + def quick_layout_switch(self, index_or_layout_id): + """ + Switch to quick layout number *index* or *layout id*. + + Parameters + ---------- + index: int or str + """ + possible_current_layout = self._plugin.quick_layout_switch( + index_or_layout_id) + + if possible_current_layout is not None: + if isinstance(possible_current_layout, int): + self._current_quick_layout = possible_current_layout + else: + names = self.get_conf('names') + self._current_quick_layout = names.index( + possible_current_layout) diff --git a/spyder/plugins/layout/layouts.py b/spyder/plugins/layout/layouts.py index 6d0385e810c..540b1fd27df 100644 --- a/spyder/plugins/layout/layouts.py +++ b/spyder/plugins/layout/layouts.py @@ -1,262 +1,262 @@ -# -*- coding: utf-8 -*- -# -# Copyright © Spyder Project Contributors -# Licensed under the terms of the MIT License -# (see spyder/__init__.py for details) - -""" -Default layout definitions. -""" - -# Third party imports -from qtpy.QtCore import QRect, QRectF, Qt -from qtpy.QtWidgets import (QApplication, QDockWidget, QGridLayout, - QMainWindow, QPlainTextEdit, QWidget) - -# Local imports -from spyder.api.plugins import Plugins -from spyder.api.translations import get_translation -from spyder.plugins.layout.api import BaseGridLayoutType - - -# Localization -_ = get_translation("spyder") - - -class DefaultLayouts: - SpyderLayout = "Spyder Default Layout" - HorizontalSplitLayout = "Horizontal split" - VerticalSplitLayout = "Vertical split" - RLayout = "Rstudio layout" - MatlabLayout = "Matlab layout" - - -class SpyderLayout(BaseGridLayoutType): - ID = DefaultLayouts.SpyderLayout - - def __init__(self, parent_plugin): - super().__init__(parent_plugin) - - self.add_area( - [Plugins.Projects], - row=0, - column=0, - row_span=2, - visible=False, - ) - self.add_area( - [Plugins.Editor], - row=0, - column=1, - row_span=2, - ) - self.add_area( - [Plugins.OutlineExplorer], - row=0, - column=2, - row_span=2, - visible=False, - ) - self.add_area( - [Plugins.Help, Plugins.VariableExplorer, Plugins.Plots, - Plugins.OnlineHelp, Plugins.Explorer, Plugins.Find], - row=0, - column=3, - default=True, - hidden_plugin_ids=[Plugins.OnlineHelp, Plugins.Find] - ) - self.add_area( - [Plugins.IPythonConsole, Plugins.History, Plugins.Console], - row=1, - column=3, - hidden_plugin_ids=[Plugins.Console] - ) - - self.set_column_stretch(0, 1) - self.set_column_stretch(1, 4) - self.set_column_stretch(2, 1) - self.set_column_stretch(3, 4) - - def get_name(self): - return _("Spyder Default Layout") - - -class HorizontalSplitLayout(BaseGridLayoutType): - ID = DefaultLayouts.HorizontalSplitLayout - - def __init__(self, parent_plugin): - super().__init__(parent_plugin) - - self.add_area( - [Plugins.Editor], - row=0, - column=0, - ) - self.add_area( - [Plugins.IPythonConsole, Plugins.Explorer, Plugins.Help, - Plugins.VariableExplorer, Plugins.Plots, Plugins.History], - row=0, - column=1, - default=True, - ) - - self.set_column_stretch(0, 5) - self.set_column_stretch(1, 4) - - def get_name(self): - return _("Horizontal split") - - -class VerticalSplitLayout(BaseGridLayoutType): - ID = DefaultLayouts.VerticalSplitLayout - - def __init__(self, parent_plugin): - super().__init__(parent_plugin) - - self.add_area( - [Plugins.Editor], - row=0, - column=0, - ) - self.add_area( - [Plugins.IPythonConsole, Plugins.Explorer, Plugins.Help, - Plugins.VariableExplorer, Plugins.Plots, Plugins.History], - row=1, - column=0, - default=True, - ) - - self.set_row_stretch(0, 6) - self.set_row_stretch(1, 4) - - def get_name(self): - return _("Vertical split") - - -class RLayout(BaseGridLayoutType): - ID = DefaultLayouts.RLayout - - def __init__(self, parent_plugin): - super().__init__(parent_plugin) - - self.add_area( - [Plugins.Editor], - row=0, - column=0, - ) - self.add_area( - [Plugins.IPythonConsole, Plugins.Console], - row=1, - column=0, - hidden_plugin_ids=[Plugins.Console] - ) - self.add_area( - [Plugins.VariableExplorer, Plugins.Plots, Plugins.History, - Plugins.OutlineExplorer, Plugins.Find], - row=0, - column=1, - default=True, - hidden_plugin_ids=[Plugins.OutlineExplorer, Plugins.Find] - ) - self.add_area( - [Plugins.Explorer, Plugins.Projects, Plugins.Help, - Plugins.OnlineHelp], - row=1, - column=1, - hidden_plugin_ids=[Plugins.Projects, Plugins.OnlineHelp] - ) - - def get_name(self): - return _("Rstudio layout") - - -class MatlabLayout(BaseGridLayoutType): - ID = DefaultLayouts.MatlabLayout - - def __init__(self, parent_plugin): - super().__init__(parent_plugin) - - self.add_area( - [Plugins.Explorer, Plugins.Projects], - row=0, - column=0, - hidden_plugin_ids=[Plugins.Projects] - ) - self.add_area( - [Plugins.OutlineExplorer], - row=1, - column=0, - ) - self.add_area( - [Plugins.Editor], - row=0, - column=1, - ) - self.add_area( - [Plugins.IPythonConsole, Plugins.Console], - row=1, - column=1, - hidden_plugin_ids=[Plugins.Console] - ) - self.add_area( - [Plugins.VariableExplorer, Plugins.Plots, Plugins.Find], - row=0, - column=2, - default=True, - hidden_plugin_ids=[Plugins.Find] - ) - self.add_area( - [Plugins.History, Plugins.Help, Plugins.OnlineHelp], - row=1, - column=2, - hidden_plugin_ids=[Plugins.OnlineHelp] - ) - - self.set_column_stretch(0, 2) - self.set_column_stretch(1, 3) - self.set_column_stretch(2, 2) - - self.set_row_stretch(0, 3) - self.set_row_stretch(1, 2) - - def get_name(self): - return _("Matlab layout") - - -class VerticalSplitLayout2(BaseGridLayoutType): - ID = "testing layout" - - def __init__(self, parent_plugin): - super().__init__(parent_plugin) - - self.add_area([Plugins.IPythonConsole], 0, 0, row_span=2) - self.add_area([Plugins.Editor], 0, 1, col_span=2) - self.add_area([Plugins.Explorer], 1, 1, default=True) - self.add_area([Plugins.Help], 1, 2) - self.add_area([Plugins.Console], 0, 3, row_span=2) - self.add_area( - [Plugins.VariableExplorer], 2, 0, col_span=4, visible=False) - - self.set_column_stretch(0, 1) - self.set_column_stretch(1, 4) - self.set_column_stretch(2, 4) - self.set_column_stretch(3, 1) - - self.set_row_stretch(0, 2) - self.set_row_stretch(1, 2) - self.set_row_stretch(2, 1) - - def get_name(self): - return _("testing layout") - - -if __name__ == "__main__": - for layout in [ - # SpyderLayout(None), - # HorizontalSplitLayout(None), - # VerticalSplitLayout(None), - # RLayout(None), - # MatlabLayout(None), - VerticalSplitLayout2(None), - ]: - layout.preview_layout(show_hidden_areas=True) +# -*- coding: utf-8 -*- +# +# Copyright © Spyder Project Contributors +# Licensed under the terms of the MIT License +# (see spyder/__init__.py for details) + +""" +Default layout definitions. +""" + +# Third party imports +from qtpy.QtCore import QRect, QRectF, Qt +from qtpy.QtWidgets import (QApplication, QDockWidget, QGridLayout, + QMainWindow, QPlainTextEdit, QWidget) + +# Local imports +from spyder.api.plugins import Plugins +from spyder.api.translations import get_translation +from spyder.plugins.layout.api import BaseGridLayoutType + + +# Localization +_ = get_translation("spyder") + + +class DefaultLayouts: + SpyderLayout = "Spyder Default Layout" + HorizontalSplitLayout = "Horizontal split" + VerticalSplitLayout = "Vertical split" + RLayout = "Rstudio layout" + MatlabLayout = "Matlab layout" + + +class SpyderLayout(BaseGridLayoutType): + ID = DefaultLayouts.SpyderLayout + + def __init__(self, parent_plugin): + super().__init__(parent_plugin) + + self.add_area( + [Plugins.Projects], + row=0, + column=0, + row_span=2, + visible=False, + ) + self.add_area( + [Plugins.Editor], + row=0, + column=1, + row_span=2, + ) + self.add_area( + [Plugins.OutlineExplorer], + row=0, + column=2, + row_span=2, + visible=False, + ) + self.add_area( + [Plugins.Help, Plugins.VariableExplorer, Plugins.Plots, + Plugins.OnlineHelp, Plugins.Explorer, Plugins.Find], + row=0, + column=3, + default=True, + hidden_plugin_ids=[Plugins.OnlineHelp, Plugins.Find] + ) + self.add_area( + [Plugins.IPythonConsole, Plugins.History, Plugins.Console], + row=1, + column=3, + hidden_plugin_ids=[Plugins.Console] + ) + + self.set_column_stretch(0, 1) + self.set_column_stretch(1, 4) + self.set_column_stretch(2, 1) + self.set_column_stretch(3, 4) + + def get_name(self): + return _("Spyder Default Layout") + + +class HorizontalSplitLayout(BaseGridLayoutType): + ID = DefaultLayouts.HorizontalSplitLayout + + def __init__(self, parent_plugin): + super().__init__(parent_plugin) + + self.add_area( + [Plugins.Editor], + row=0, + column=0, + ) + self.add_area( + [Plugins.IPythonConsole, Plugins.Explorer, Plugins.Help, + Plugins.VariableExplorer, Plugins.Plots, Plugins.History], + row=0, + column=1, + default=True, + ) + + self.set_column_stretch(0, 5) + self.set_column_stretch(1, 4) + + def get_name(self): + return _("Horizontal split") + + +class VerticalSplitLayout(BaseGridLayoutType): + ID = DefaultLayouts.VerticalSplitLayout + + def __init__(self, parent_plugin): + super().__init__(parent_plugin) + + self.add_area( + [Plugins.Editor], + row=0, + column=0, + ) + self.add_area( + [Plugins.IPythonConsole, Plugins.Explorer, Plugins.Help, + Plugins.VariableExplorer, Plugins.Plots, Plugins.History], + row=1, + column=0, + default=True, + ) + + self.set_row_stretch(0, 6) + self.set_row_stretch(1, 4) + + def get_name(self): + return _("Vertical split") + + +class RLayout(BaseGridLayoutType): + ID = DefaultLayouts.RLayout + + def __init__(self, parent_plugin): + super().__init__(parent_plugin) + + self.add_area( + [Plugins.Editor], + row=0, + column=0, + ) + self.add_area( + [Plugins.IPythonConsole, Plugins.Console], + row=1, + column=0, + hidden_plugin_ids=[Plugins.Console] + ) + self.add_area( + [Plugins.VariableExplorer, Plugins.Plots, Plugins.History, + Plugins.OutlineExplorer, Plugins.Find], + row=0, + column=1, + default=True, + hidden_plugin_ids=[Plugins.OutlineExplorer, Plugins.Find] + ) + self.add_area( + [Plugins.Explorer, Plugins.Projects, Plugins.Help, + Plugins.OnlineHelp], + row=1, + column=1, + hidden_plugin_ids=[Plugins.Projects, Plugins.OnlineHelp] + ) + + def get_name(self): + return _("Rstudio layout") + + +class MatlabLayout(BaseGridLayoutType): + ID = DefaultLayouts.MatlabLayout + + def __init__(self, parent_plugin): + super().__init__(parent_plugin) + + self.add_area( + [Plugins.Explorer, Plugins.Projects], + row=0, + column=0, + hidden_plugin_ids=[Plugins.Projects] + ) + self.add_area( + [Plugins.OutlineExplorer], + row=1, + column=0, + ) + self.add_area( + [Plugins.Editor], + row=0, + column=1, + ) + self.add_area( + [Plugins.IPythonConsole, Plugins.Console], + row=1, + column=1, + hidden_plugin_ids=[Plugins.Console] + ) + self.add_area( + [Plugins.VariableExplorer, Plugins.Plots, Plugins.Find], + row=0, + column=2, + default=True, + hidden_plugin_ids=[Plugins.Find] + ) + self.add_area( + [Plugins.History, Plugins.Help, Plugins.OnlineHelp], + row=1, + column=2, + hidden_plugin_ids=[Plugins.OnlineHelp] + ) + + self.set_column_stretch(0, 2) + self.set_column_stretch(1, 3) + self.set_column_stretch(2, 2) + + self.set_row_stretch(0, 3) + self.set_row_stretch(1, 2) + + def get_name(self): + return _("Matlab layout") + + +class VerticalSplitLayout2(BaseGridLayoutType): + ID = "testing layout" + + def __init__(self, parent_plugin): + super().__init__(parent_plugin) + + self.add_area([Plugins.IPythonConsole], 0, 0, row_span=2) + self.add_area([Plugins.Editor], 0, 1, col_span=2) + self.add_area([Plugins.Explorer], 1, 1, default=True) + self.add_area([Plugins.Help], 1, 2) + self.add_area([Plugins.Console], 0, 3, row_span=2) + self.add_area( + [Plugins.VariableExplorer], 2, 0, col_span=4, visible=False) + + self.set_column_stretch(0, 1) + self.set_column_stretch(1, 4) + self.set_column_stretch(2, 4) + self.set_column_stretch(3, 1) + + self.set_row_stretch(0, 2) + self.set_row_stretch(1, 2) + self.set_row_stretch(2, 1) + + def get_name(self): + return _("testing layout") + + +if __name__ == "__main__": + for layout in [ + # SpyderLayout(None), + # HorizontalSplitLayout(None), + # VerticalSplitLayout(None), + # RLayout(None), + # MatlabLayout(None), + VerticalSplitLayout2(None), + ]: + layout.preview_layout(show_hidden_areas=True) diff --git a/spyder/plugins/layout/plugin.py b/spyder/plugins/layout/plugin.py index 3d090dfa25f..d210ded0878 100644 --- a/spyder/plugins/layout/plugin.py +++ b/spyder/plugins/layout/plugin.py @@ -1,829 +1,829 @@ -# -*- coding: utf-8 -*- -# -# Copyright © Spyder Project Contributors -# Licensed under the terms of the MIT License -# (see spyder/__init__.py for details) - -""" -Layout Plugin. -""" -# Standard library imports -import configparser as cp -import os - -# Third party imports -from qtpy.QtCore import Qt, QByteArray, QSize, QPoint, Slot -from qtpy.QtWidgets import QApplication, QDesktopWidget, QDockWidget - -# Local imports -from spyder.api.exceptions import SpyderAPIError -from spyder.api.plugins import Plugins, SpyderPluginV2 -from spyder.api.plugin_registration.decorators import ( - on_plugin_available, on_plugin_teardown) -from spyder.api.translations import get_translation -from spyder.api.utils import get_class_values -from spyder.plugins.mainmenu.api import ApplicationMenus, ViewMenuSections -from spyder.plugins.layout.container import ( - LayoutContainer, LayoutContainerActions, LayoutPluginMenus) -from spyder.plugins.layout.layouts import (DefaultLayouts, - HorizontalSplitLayout, - MatlabLayout, RLayout, - SpyderLayout, VerticalSplitLayout) -from spyder.plugins.preferences.widgets.container import PreferencesActions -from spyder.plugins.toolbar.api import ( - ApplicationToolbars, MainToolbarSections) -from spyder.py3compat import qbytearray_to_str # FIXME: - - -# Localization -_ = get_translation("spyder") - -# Constants - -# Number of default layouts available -DEFAULT_LAYOUTS = get_class_values(DefaultLayouts) - -# ---------------------------------------------------------------------------- -# ---- Window state version passed to saveState/restoreState. -# ---------------------------------------------------------------------------- -# This defines the layout version used by different Spyder releases. In case -# there's a need to reset the layout when moving from one release to another, -# please increase the number below in integer steps, e.g. from 1 to 2, and -# leave a mention below explaining what prompted the change. -# -# The current versions are: -# -# * Spyder 4: Version 0 (it was the default). -# * Spyder 5.0.0 to 5.0.5: Version 1 (a bump was required due to the new API). -# * Spyder 5.1.0: Version 2 (a bump was required due to the migration of -# Projects to the new API). -# * Spyder 5.2.0: Version 3 (a bump was required due to the migration of -# IPython Console to the new API) -WINDOW_STATE_VERSION = 3 - - -class Layout(SpyderPluginV2): - """ - Layout manager plugin. - """ - NAME = "layout" - CONF_SECTION = "quick_layouts" - REQUIRES = [Plugins.All] # Uses wildcard to require all the plugins - CONF_FILE = False - CONTAINER_CLASS = LayoutContainer - CAN_BE_DISABLED = False - - # --- SpyderDockablePlugin API - # ------------------------------------------------------------------------ - @staticmethod - def get_name(): - return _("Layout") - - def get_description(self): - return _("Layout manager") - - def get_icon(self): - return self.create_icon("history") # FIXME: - - def on_initialize(self): - self._last_plugin = None - self._first_spyder_run = False - self._fullscreen_flag = None - # The following flag remember the maximized state even when - # the window is in fullscreen mode: - self._maximized_flag = None - # The following flag is used to restore window's geometry when - # toggling out of fullscreen mode in Windows. - self._saved_normal_geometry = None - self._state_before_maximizing = None - self._interface_locked = self.get_conf('panes_locked', section='main') - - # Register default layouts - self.register_layout(self, SpyderLayout) - self.register_layout(self, RLayout) - self.register_layout(self, MatlabLayout) - self.register_layout(self, HorizontalSplitLayout) - self.register_layout(self, VerticalSplitLayout) - - self._update_fullscreen_action() - - @on_plugin_available(plugin=Plugins.MainMenu) - def on_main_menu_available(self): - mainmenu = self.get_plugin(Plugins.MainMenu) - container = self.get_container() - # Add Panes related actions to View application menu - panes_items = [ - container._plugins_menu, - container._lock_interface_action, - container._close_dockwidget_action, - container._maximize_dockwidget_action] - for panes_item in panes_items: - mainmenu.add_item_to_application_menu( - panes_item, - menu_id=ApplicationMenus.View, - section=ViewMenuSections.Pane, - before_section=ViewMenuSections.Toolbar) - # Add layouts menu to View application menu - layout_items = [ - container._layouts_menu, - container._toggle_next_layout_action, - container._toggle_previous_layout_action] - for layout_item in layout_items: - mainmenu.add_item_to_application_menu( - layout_item, - menu_id=ApplicationMenus.View, - section=ViewMenuSections.Layout, - before_section=ViewMenuSections.Bottom) - # Add fullscreen action to View application menu - mainmenu.add_item_to_application_menu( - container._fullscreen_action, - menu_id=ApplicationMenus.View, - section=ViewMenuSections.Bottom) - - @on_plugin_available(plugin=Plugins.Toolbar) - def on_toolbar_available(self): - container = self.get_container() - toolbars = self.get_plugin(Plugins.Toolbar) - # Add actions to Main application toolbar - toolbars.add_item_to_application_toolbar( - container._maximize_dockwidget_action, - toolbar_id=ApplicationToolbars.Main, - section=MainToolbarSections.ApplicationSection, - before=PreferencesActions.Show - ) - - @on_plugin_teardown(plugin=Plugins.MainMenu) - def on_main_menu_teardown(self): - mainmenu = self.get_plugin(Plugins.MainMenu) - # Remove Panes related actions from the View application menu - panes_items = [ - LayoutPluginMenus.PluginsMenu, - LayoutContainerActions.LockDockwidgetsAndToolbars, - LayoutContainerActions.CloseCurrentDockwidget, - LayoutContainerActions.MaximizeCurrentDockwidget] - for panes_item in panes_items: - mainmenu.remove_item_from_application_menu( - panes_item, - menu_id=ApplicationMenus.View) - # Remove layouts menu from the View application menu - layout_items = [ - LayoutPluginMenus.LayoutsMenu, - LayoutContainerActions.NextLayout, - LayoutContainerActions.PreviousLayout] - for layout_item in layout_items: - mainmenu.remove_item_from_application_menu( - layout_item, - menu_id=ApplicationMenus.View) - # Remove fullscreen action from the View application menu - mainmenu.remove_item_from_application_menu( - LayoutContainerActions.Fullscreen, - menu_id=ApplicationMenus.View) - - @on_plugin_teardown(plugin=Plugins.Toolbar) - def on_toolbar_teardown(self): - toolbars = self.get_plugin(Plugins.Toolbar) - - # Remove actions from the Main application toolbar - toolbars.remove_item_from_application_toolbar( - LayoutContainerActions.MaximizeCurrentDockwidget, - toolbar_id=ApplicationToolbars.Main - ) - - def before_mainwindow_visible(self): - # Update layout menu - self.update_layout_menu_actions() - # Setup layout - self.setup_layout(default=False) - - def on_mainwindow_visible(self): - # Populate panes menu - self.create_plugins_menu() - # Update panes and toolbars lock status - self.toggle_lock(self._interface_locked) - - # --- Plubic API - # ------------------------------------------------------------------------ - def get_last_plugin(self): - """ - Return the last focused dockable plugin. - - Returns - ------- - SpyderDockablePlugin - The last focused dockable plugin. - """ - return self._last_plugin - - def get_fullscreen_flag(self): - """ - Give access to the fullscreen flag. - - The flag shows if the mainwindow is in fullscreen mode or not. - - Returns - ------- - bool - True is the mainwindow is in fullscreen. False otherwise. - """ - return self._fullscreen_flag - - def register_layout(self, parent_plugin, layout_type): - """ - Register a new layout type. - - Parameters - ---------- - parent_plugin: spyder.api.plugins.SpyderPluginV2 - Plugin registering the layout type. - layout_type: spyder.plugins.layout.api.BaseGridLayoutType - Layout to register. - """ - self.get_container().register_layout(parent_plugin, layout_type) - - def get_layout(self, layout_id): - """ - Get a registered layout by his ID. - - Parameters - ---------- - layout_id : string - The ID of the layout. - - Returns - ------- - Instance of a spyder.plugins.layout.api.BaseGridLayoutType subclass - Layout. - """ - return self.get_container().get_layout(layout_id) - - def update_layout_menu_actions(self): - self.get_container().update_layout_menu_actions() - - def setup_layout(self, default=False): - """Initialize mainwindow layout.""" - prefix = 'window' + '/' - settings = self.load_window_settings(prefix, default) - hexstate = settings[0] - - self._first_spyder_run = False - if hexstate is None: - # First Spyder execution: - self.main.setWindowState(Qt.WindowMaximized) - self._first_spyder_run = True - self.setup_default_layouts(DefaultLayouts.SpyderLayout, settings) - - # Now that the initial setup is done, copy the window settings, - # except for the hexstate in the quick layouts sections for the - # default layouts. - # Order and name of the default layouts is found in config.py - section = 'quick_layouts' - get_func = self.get_conf_default if default else self.get_conf - order = get_func('order', section=section) - - # Restore the original defaults if reset layouts is called - if default: - self.set_conf('active', order, section) - self.set_conf('order', order, section) - self.set_conf('names', order, section) - self.set_conf('ui_names', order, section) - - for index, _name, in enumerate(order): - prefix = 'layout_{0}/'.format(index) - self.save_current_window_settings(prefix, section, - none_state=True) - - # Store the initial layout as the default in spyder - prefix = 'layout_default/' - section = 'quick_layouts' - self.save_current_window_settings(prefix, section, none_state=True) - self._current_quick_layout = DefaultLayouts.SpyderLayout - - self.set_window_settings(*settings) - - def setup_default_layouts(self, layout_id, settings): - """Setup default layouts when run for the first time.""" - main = self.main - main.setUpdatesEnabled(False) - - first_spyder_run = bool(self._first_spyder_run) # Store copy - - if first_spyder_run: - self.set_window_settings(*settings) - else: - if self._last_plugin: - if self._last_plugin._ismaximized: - self.maximize_dockwidget(restore=True) - - if not (main.isMaximized() or self._maximized_flag): - main.showMaximized() - - min_width = main.minimumWidth() - max_width = main.maximumWidth() - base_width = main.width() - main.setFixedWidth(base_width) - - # Layout selection - layout = self.get_layout(layout_id) - - # Apply selected layout - layout.set_main_window_layout(self.main, self.get_dockable_plugins()) - - if first_spyder_run: - self._first_spyder_run = False - else: - self.main.setMinimumWidth(min_width) - self.main.setMaximumWidth(max_width) - - if not (self.main.isMaximized() or self._maximized_flag): - self.main.showMaximized() - - self.main.setUpdatesEnabled(True) - self.main.sig_layout_setup_ready.emit(layout) - - return layout - - def quick_layout_switch(self, index_or_layout_id): - """ - Switch to quick layout. - - Using a number *index* or a registered layout id *layout_id*. - - Parameters - ---------- - index_or_layout_id: int or str - """ - section = 'quick_layouts' - 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 - - # The defaults layouts will always be regenerated unless there was - # an overwrite, either by rewriting with same name, or by deleting - # and then creating a new one - if hexstate is None: - # The value for hexstate shouldn't be None for a custom saved - # layout (ie, where the index is greater than the number of - # defaults). See spyder-ide/spyder#6202. - if index_or_layout_id not in DEFAULT_LAYOUTS: - container.critical_message( - _("Warning"), - _("Error opening the custom layout. Please close" - " Spyder and try again. If the issue persists," - " then you must use 'Reset to Spyder default' " - "from the layout menu.")) - return - self.setup_default_layouts(index_or_layout_id, settings) - else: - self.set_window_settings(*settings) - except cp.NoOptionError: - try: - layout = self.get_layout(index_or_layout_id) - layout.set_main_window_layout( - self.main, self.get_dockable_plugins()) - self.main.sig_layout_setup_ready.emit(layout) - except SpyderAPIError: - container.critical_message( - _("Warning"), - _("Quick switch layout #%s has not yet " - "been defined.") % str(index_or_layout_id)) - - # Make sure the flags are correctly set for visible panes - for plugin in self.get_dockable_plugins(): - try: - # New API - action = plugin.toggle_view_action - except AttributeError: - # Old API - action = plugin._toggle_view_action - action.setChecked(plugin.dockwidget.isVisible()) - - return index_or_layout_id - - def load_window_settings(self, prefix, default=False, section='main'): - """ - Load window layout settings from userconfig-based configuration with - *prefix*, under *section*. - - Parameters - ---------- - default: bool - if True, do not restore inner layout. - """ - 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 - else: - try: - hexstate = get_func(prefix + 'state', section=section) - except Exception: - hexstate = None - - pos = get_func(prefix + 'position', section=section) - - # It's necessary to verify if the window/position value is valid - # with the current screen. See spyder-ide/spyder#3748. - width = pos[0] - height = pos[1] - screen_shape = QApplication.desktop().geometry() - current_width = screen_shape.width() - current_height = screen_shape.height() - if current_width < width or current_height < height: - pos = self.get_conf_default(prefix + 'position', section) - - 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) - - def get_window_settings(self): - """ - Return current window settings. - - Symetric to the 'set_window_settings' setter. - """ - # FIXME: Window size in main window is update on resize - window_size = (self.window_size.width(), self.window_size.height()) - - is_fullscreen = self.main.isFullScreen() - if is_fullscreen: - is_maximized = self._maximized_flag - else: - 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) - - def set_window_settings(self, hexstate, window_size, prefs_dialog_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 - main.setWindowState(Qt.WindowNoState) - main.resize(self.window_size) - main.move(self.window_position) - - # Window layout - if hexstate: - hexstate_valid = self.main.restoreState( - QByteArray().fromHex(str(hexstate).encode('utf-8')), - version=WINDOW_STATE_VERSION - ) - - # Check layout validity. Spyder 4 and below use the version 0 - # state (default), whereas Spyder 5 will use version 1 state. - # For more info see the version argument for - # QMainWindow.restoreState: - # https://doc.qt.io/qt-5/qmainwindow.html#restoreState - if not hexstate_valid: - self.main.setUpdatesEnabled(True) - self.setup_layout(default=True) - return - - # Is fullscreen? - if is_fullscreen: - self.main.setWindowState(Qt.WindowFullScreen) - - # Is maximized? - if is_fullscreen: - self._maximized_flag = is_maximized - elif is_maximized: - self.main.setWindowState(Qt.WindowMaximized) - - self.main.setUpdatesEnabled(True) - - def save_current_window_settings(self, prefix, section='main', - none_state=False): - """ - Save current window settings. - - It saves config with *prefix* in the userconfig-based, - configuration under *section*. - """ - # Use current size and position when saving window settings. - # 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(), - section=section, - ) - self.set_conf( - prefix + 'is_fullscreen', - self.main.isFullScreen(), - section=section, - ) - self.set_conf( - prefix + 'position', - (pos.x(), pos.y()), - section=section, - ) - - self.maximize_dockwidget(restore=True) # Restore non-maximized layout - - if none_state: - self.set_conf( - prefix + 'state', - None, - section=section, - ) - else: - qba = self.main.saveState(version=WINDOW_STATE_VERSION) - self.set_conf( - prefix + 'state', - qbytearray_to_str(qba), - section=section, - ) - - self.set_conf( - prefix + 'statusbar', - not self.main.statusBar().isHidden(), - section=section, - ) - - @Slot() - def close_current_dockwidget(self): - """Search for the currently focused plugin and close it.""" - widget = QApplication.focusWidget() - for plugin in self.get_dockable_plugins(): - # TODO: remove old API - try: - # New API - if plugin.get_widget().isAncestorOf(widget): - plugin.toggle_view_action.setChecked(False) - break - except AttributeError: - # Old API - if plugin.isAncestorOf(widget): - plugin._toggle_view_action.setChecked(False) - break - - @property - def maximize_action(self): - """Expose maximize current dockwidget action.""" - return self.get_container()._maximize_dockwidget_action - - def maximize_dockwidget(self, restore=False): - """ - Maximize current dockwidget. - - Shortcut: Ctrl+Alt+Shift+M - First call: maximize current dockwidget - Second call (or restore=True): restore original window layout - """ - if self._state_before_maximizing is None: - if restore: - return - - # Select plugin to maximize - self._state_before_maximizing = self.main.saveState( - version=WINDOW_STATE_VERSION - ) - focus_widget = QApplication.focusWidget() - - for plugin in self.get_dockable_plugins(): - plugin.dockwidget.hide() - - try: - # New API - if plugin.get_widget().isAncestorOf(focus_widget): - self._last_plugin = plugin - except Exception: - # Old API - if plugin.isAncestorOf(focus_widget): - self._last_plugin = plugin - - # Only plugins that have a dockwidget are part of widgetlist, - # so last_plugin can be None after the above "for" cycle. - # For example, this happens if, after Spyder has started, focus - # is set to the Working directory toolbar (which doesn't have - # a dockwidget) and then you press the Maximize button - if self._last_plugin is None: - # Using the Editor as default plugin to maximize - self._last_plugin = self.get_plugin(Plugins.Editor) - - # Maximize last_plugin - self._last_plugin.dockwidget.toggleViewAction().setDisabled(True) - try: - # New API - self.main.setCentralWidget(self._last_plugin.get_widget()) - except AttributeError: - # Old API - self.main.setCentralWidget(self._last_plugin) - self._last_plugin._ismaximized = True - - # Workaround to solve an issue with editor's outline explorer: - # (otherwise the whole plugin is hidden and so is the outline - # explorer and the latter won't be refreshed if not visible) - try: - # New API - self._last_plugin.get_widget().show() - self._last_plugin.change_visibility(True) - except AttributeError: - # Old API - self._last_plugin.show() - self._last_plugin._visibility_changed(True) - - if self._last_plugin is self.main.editor: - # Automatically show the outline if the editor was maximized: - outline_explorer = self.get_plugin(Plugins.OutlineExplorer) - self.main.addDockWidget( - Qt.RightDockWidgetArea, - outline_explorer.dockwidget) - outline_explorer.dockwidget.show() - else: - # Restore original layout (before maximizing current dockwidget) - try: - # New API - self._last_plugin.dockwidget.setWidget( - self._last_plugin.get_widget()) - except AttributeError: - # Old API - self._last_plugin.dockwidget.setWidget(self._last_plugin) - self._last_plugin.dockwidget.toggleViewAction().setEnabled(True) - self.main.setCentralWidget(None) - - try: - # New API - self._last_plugin.get_widget().is_maximized = False - except AttributeError: - # Old API - self._last_plugin._ismaximized = False - - self.main.restoreState( - self._state_before_maximizing, version=WINDOW_STATE_VERSION - ) - self._state_before_maximizing = None - try: - # New API - self._last_plugin.get_widget().get_focus_widget().setFocus() - except AttributeError: - # Old API - self._last_plugin.get_focus_widget().setFocus() - - def _update_fullscreen_action(self): - if self._fullscreen_flag: - icon = self.create_icon('window_nofullscreen') - else: - icon = self.create_icon('window_fullscreen') - self.get_container()._fullscreen_action.setIcon(icon) - - @Slot() - def toggle_fullscreen(self): - """ - Toggle option to show the mainwindow in fullscreen or windowed. - """ - main = self.main - if self._fullscreen_flag: - self._fullscreen_flag = False - if os.name == 'nt': - main.setWindowFlags( - main.windowFlags() - ^ (Qt.FramelessWindowHint | Qt.WindowStaysOnTopHint)) - main.setGeometry(self._saved_normal_geometry) - main.showNormal() - if self._maximized_flag: - main.showMaximized() - else: - self._maximized_flag = main.isMaximized() - self._fullscreen_flag = True - self._saved_normal_geometry = main.normalGeometry() - if os.name == 'nt': - # Due to limitations of the Windows DWM, compositing is not - # handled correctly for OpenGL based windows when going into - # full screen mode, so we need to use this workaround. - # See spyder-ide/spyder#4291. - main.setWindowFlags(main.windowFlags() - | Qt.FramelessWindowHint - | Qt.WindowStaysOnTopHint) - - screen_number = QDesktopWidget().screenNumber(main) - if screen_number < 0: - screen_number = 0 - - r = QApplication.desktop().screenGeometry(screen_number) - main.setGeometry( - r.left() - 1, r.top() - 1, r.width() + 2, r.height() + 2) - main.showNormal() - else: - main.showFullScreen() - self._update_fullscreen_action() - - @property - def plugins_menu(self): - """Expose plugins toggle actions menu.""" - return self.get_container()._plugins_menu - - def create_plugins_menu(self): - """ - Populate panes menu with the toggle view action of each base plugin. - """ - order = ['editor', 'ipython_console', 'variable_explorer', - 'help', 'plots', None, 'explorer', 'outline_explorer', - 'project_explorer', 'find_in_files', None, 'historylog', - 'profiler', 'breakpoints', 'pylint', None, - 'onlinehelp', 'internal_console', None] - - for plugin in self.get_dockable_plugins(): - try: - # New API - action = plugin.toggle_view_action - except AttributeError: - # Old API - action = plugin._toggle_view_action - action.action_id = f'switch to {plugin.CONF_SECTION}' - - if action: - action.setChecked(plugin.dockwidget.isVisible()) - - try: - name = plugin.CONF_SECTION - pos = order.index(name) - except ValueError: - pos = None - - if pos is not None: - order[pos] = action - else: - order.append(action) - - actions = order[:] - for action in actions: - if type(action) is not str: - self.get_container()._plugins_menu.add_action(action) - - @property - def lock_interface_action(self): - return self.get_container()._lock_interface_action - - def _update_lock_interface_action(self): - """ - Helper method to update the locking of panes/dockwidgets and toolbars. - - Returns - ------- - None. - """ - if self._interface_locked: - icon = self.create_icon('drag_dock_widget') - text = _('Unlock panes and toolbars') - else: - icon = self.create_icon('lock') - text = _('Lock panes and toolbars') - self.lock_interface_action.setIcon(icon) - self.lock_interface_action.setText(text) - - def toggle_lock(self, value=None): - """Lock/Unlock dockwidgets and toolbars.""" - self._interface_locked = ( - not self._interface_locked if value is None else value) - self.set_conf('panes_locked', self._interface_locked, 'main') - self._update_lock_interface_action() - # Apply lock to panes - for plugin in self.get_dockable_plugins(): - if self._interface_locked: - if plugin.dockwidget.isFloating(): - plugin.dockwidget.setFloating(False) - - plugin.dockwidget.remove_title_bar() - else: - plugin.dockwidget.set_title_bar() - - # Apply lock to toolbars - toolbar = self.get_plugin(Plugins.Toolbar) - if toolbar: - toolbar.toggle_lock(value=self._interface_locked) +# -*- coding: utf-8 -*- +# +# Copyright © Spyder Project Contributors +# Licensed under the terms of the MIT License +# (see spyder/__init__.py for details) + +""" +Layout Plugin. +""" +# Standard library imports +import configparser as cp +import os + +# Third party imports +from qtpy.QtCore import Qt, QByteArray, QSize, QPoint, Slot +from qtpy.QtWidgets import QApplication, QDesktopWidget, QDockWidget + +# Local imports +from spyder.api.exceptions import SpyderAPIError +from spyder.api.plugins import Plugins, SpyderPluginV2 +from spyder.api.plugin_registration.decorators import ( + on_plugin_available, on_plugin_teardown) +from spyder.api.translations import get_translation +from spyder.api.utils import get_class_values +from spyder.plugins.mainmenu.api import ApplicationMenus, ViewMenuSections +from spyder.plugins.layout.container import ( + LayoutContainer, LayoutContainerActions, LayoutPluginMenus) +from spyder.plugins.layout.layouts import (DefaultLayouts, + HorizontalSplitLayout, + MatlabLayout, RLayout, + SpyderLayout, VerticalSplitLayout) +from spyder.plugins.preferences.widgets.container import PreferencesActions +from spyder.plugins.toolbar.api import ( + ApplicationToolbars, MainToolbarSections) +from spyder.py3compat import qbytearray_to_str # FIXME: + + +# Localization +_ = get_translation("spyder") + +# Constants + +# Number of default layouts available +DEFAULT_LAYOUTS = get_class_values(DefaultLayouts) + +# ---------------------------------------------------------------------------- +# ---- Window state version passed to saveState/restoreState. +# ---------------------------------------------------------------------------- +# This defines the layout version used by different Spyder releases. In case +# there's a need to reset the layout when moving from one release to another, +# please increase the number below in integer steps, e.g. from 1 to 2, and +# leave a mention below explaining what prompted the change. +# +# The current versions are: +# +# * Spyder 4: Version 0 (it was the default). +# * Spyder 5.0.0 to 5.0.5: Version 1 (a bump was required due to the new API). +# * Spyder 5.1.0: Version 2 (a bump was required due to the migration of +# Projects to the new API). +# * Spyder 5.2.0: Version 3 (a bump was required due to the migration of +# IPython Console to the new API) +WINDOW_STATE_VERSION = 3 + + +class Layout(SpyderPluginV2): + """ + Layout manager plugin. + """ + NAME = "layout" + CONF_SECTION = "quick_layouts" + REQUIRES = [Plugins.All] # Uses wildcard to require all the plugins + CONF_FILE = False + CONTAINER_CLASS = LayoutContainer + CAN_BE_DISABLED = False + + # --- SpyderDockablePlugin API + # ------------------------------------------------------------------------ + @staticmethod + def get_name(): + return _("Layout") + + def get_description(self): + return _("Layout manager") + + def get_icon(self): + return self.create_icon("history") # FIXME: + + def on_initialize(self): + self._last_plugin = None + self._first_spyder_run = False + self._fullscreen_flag = None + # The following flag remember the maximized state even when + # the window is in fullscreen mode: + self._maximized_flag = None + # The following flag is used to restore window's geometry when + # toggling out of fullscreen mode in Windows. + self._saved_normal_geometry = None + self._state_before_maximizing = None + self._interface_locked = self.get_conf('panes_locked', section='main') + + # Register default layouts + self.register_layout(self, SpyderLayout) + self.register_layout(self, RLayout) + self.register_layout(self, MatlabLayout) + self.register_layout(self, HorizontalSplitLayout) + self.register_layout(self, VerticalSplitLayout) + + self._update_fullscreen_action() + + @on_plugin_available(plugin=Plugins.MainMenu) + def on_main_menu_available(self): + mainmenu = self.get_plugin(Plugins.MainMenu) + container = self.get_container() + # Add Panes related actions to View application menu + panes_items = [ + container._plugins_menu, + container._lock_interface_action, + container._close_dockwidget_action, + container._maximize_dockwidget_action] + for panes_item in panes_items: + mainmenu.add_item_to_application_menu( + panes_item, + menu_id=ApplicationMenus.View, + section=ViewMenuSections.Pane, + before_section=ViewMenuSections.Toolbar) + # Add layouts menu to View application menu + layout_items = [ + container._layouts_menu, + container._toggle_next_layout_action, + container._toggle_previous_layout_action] + for layout_item in layout_items: + mainmenu.add_item_to_application_menu( + layout_item, + menu_id=ApplicationMenus.View, + section=ViewMenuSections.Layout, + before_section=ViewMenuSections.Bottom) + # Add fullscreen action to View application menu + mainmenu.add_item_to_application_menu( + container._fullscreen_action, + menu_id=ApplicationMenus.View, + section=ViewMenuSections.Bottom) + + @on_plugin_available(plugin=Plugins.Toolbar) + def on_toolbar_available(self): + container = self.get_container() + toolbars = self.get_plugin(Plugins.Toolbar) + # Add actions to Main application toolbar + toolbars.add_item_to_application_toolbar( + container._maximize_dockwidget_action, + toolbar_id=ApplicationToolbars.Main, + section=MainToolbarSections.ApplicationSection, + before=PreferencesActions.Show + ) + + @on_plugin_teardown(plugin=Plugins.MainMenu) + def on_main_menu_teardown(self): + mainmenu = self.get_plugin(Plugins.MainMenu) + # Remove Panes related actions from the View application menu + panes_items = [ + LayoutPluginMenus.PluginsMenu, + LayoutContainerActions.LockDockwidgetsAndToolbars, + LayoutContainerActions.CloseCurrentDockwidget, + LayoutContainerActions.MaximizeCurrentDockwidget] + for panes_item in panes_items: + mainmenu.remove_item_from_application_menu( + panes_item, + menu_id=ApplicationMenus.View) + # Remove layouts menu from the View application menu + layout_items = [ + LayoutPluginMenus.LayoutsMenu, + LayoutContainerActions.NextLayout, + LayoutContainerActions.PreviousLayout] + for layout_item in layout_items: + mainmenu.remove_item_from_application_menu( + layout_item, + menu_id=ApplicationMenus.View) + # Remove fullscreen action from the View application menu + mainmenu.remove_item_from_application_menu( + LayoutContainerActions.Fullscreen, + menu_id=ApplicationMenus.View) + + @on_plugin_teardown(plugin=Plugins.Toolbar) + def on_toolbar_teardown(self): + toolbars = self.get_plugin(Plugins.Toolbar) + + # Remove actions from the Main application toolbar + toolbars.remove_item_from_application_toolbar( + LayoutContainerActions.MaximizeCurrentDockwidget, + toolbar_id=ApplicationToolbars.Main + ) + + def before_mainwindow_visible(self): + # Update layout menu + self.update_layout_menu_actions() + # Setup layout + self.setup_layout(default=False) + + def on_mainwindow_visible(self): + # Populate panes menu + self.create_plugins_menu() + # Update panes and toolbars lock status + self.toggle_lock(self._interface_locked) + + # --- Plubic API + # ------------------------------------------------------------------------ + def get_last_plugin(self): + """ + Return the last focused dockable plugin. + + Returns + ------- + SpyderDockablePlugin + The last focused dockable plugin. + """ + return self._last_plugin + + def get_fullscreen_flag(self): + """ + Give access to the fullscreen flag. + + The flag shows if the mainwindow is in fullscreen mode or not. + + Returns + ------- + bool + True is the mainwindow is in fullscreen. False otherwise. + """ + return self._fullscreen_flag + + def register_layout(self, parent_plugin, layout_type): + """ + Register a new layout type. + + Parameters + ---------- + parent_plugin: spyder.api.plugins.SpyderPluginV2 + Plugin registering the layout type. + layout_type: spyder.plugins.layout.api.BaseGridLayoutType + Layout to register. + """ + self.get_container().register_layout(parent_plugin, layout_type) + + def get_layout(self, layout_id): + """ + Get a registered layout by his ID. + + Parameters + ---------- + layout_id : string + The ID of the layout. + + Returns + ------- + Instance of a spyder.plugins.layout.api.BaseGridLayoutType subclass + Layout. + """ + return self.get_container().get_layout(layout_id) + + def update_layout_menu_actions(self): + self.get_container().update_layout_menu_actions() + + def setup_layout(self, default=False): + """Initialize mainwindow layout.""" + prefix = 'window' + '/' + settings = self.load_window_settings(prefix, default) + hexstate = settings[0] + + self._first_spyder_run = False + if hexstate is None: + # First Spyder execution: + self.main.setWindowState(Qt.WindowMaximized) + self._first_spyder_run = True + self.setup_default_layouts(DefaultLayouts.SpyderLayout, settings) + + # Now that the initial setup is done, copy the window settings, + # except for the hexstate in the quick layouts sections for the + # default layouts. + # Order and name of the default layouts is found in config.py + section = 'quick_layouts' + get_func = self.get_conf_default if default else self.get_conf + order = get_func('order', section=section) + + # Restore the original defaults if reset layouts is called + if default: + self.set_conf('active', order, section) + self.set_conf('order', order, section) + self.set_conf('names', order, section) + self.set_conf('ui_names', order, section) + + for index, _name, in enumerate(order): + prefix = 'layout_{0}/'.format(index) + self.save_current_window_settings(prefix, section, + none_state=True) + + # Store the initial layout as the default in spyder + prefix = 'layout_default/' + section = 'quick_layouts' + self.save_current_window_settings(prefix, section, none_state=True) + self._current_quick_layout = DefaultLayouts.SpyderLayout + + self.set_window_settings(*settings) + + def setup_default_layouts(self, layout_id, settings): + """Setup default layouts when run for the first time.""" + main = self.main + main.setUpdatesEnabled(False) + + first_spyder_run = bool(self._first_spyder_run) # Store copy + + if first_spyder_run: + self.set_window_settings(*settings) + else: + if self._last_plugin: + if self._last_plugin._ismaximized: + self.maximize_dockwidget(restore=True) + + if not (main.isMaximized() or self._maximized_flag): + main.showMaximized() + + min_width = main.minimumWidth() + max_width = main.maximumWidth() + base_width = main.width() + main.setFixedWidth(base_width) + + # Layout selection + layout = self.get_layout(layout_id) + + # Apply selected layout + layout.set_main_window_layout(self.main, self.get_dockable_plugins()) + + if first_spyder_run: + self._first_spyder_run = False + else: + self.main.setMinimumWidth(min_width) + self.main.setMaximumWidth(max_width) + + if not (self.main.isMaximized() or self._maximized_flag): + self.main.showMaximized() + + self.main.setUpdatesEnabled(True) + self.main.sig_layout_setup_ready.emit(layout) + + return layout + + def quick_layout_switch(self, index_or_layout_id): + """ + Switch to quick layout. + + Using a number *index* or a registered layout id *layout_id*. + + Parameters + ---------- + index_or_layout_id: int or str + """ + section = 'quick_layouts' + 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 + + # The defaults layouts will always be regenerated unless there was + # an overwrite, either by rewriting with same name, or by deleting + # and then creating a new one + if hexstate is None: + # The value for hexstate shouldn't be None for a custom saved + # layout (ie, where the index is greater than the number of + # defaults). See spyder-ide/spyder#6202. + if index_or_layout_id not in DEFAULT_LAYOUTS: + container.critical_message( + _("Warning"), + _("Error opening the custom layout. Please close" + " Spyder and try again. If the issue persists," + " then you must use 'Reset to Spyder default' " + "from the layout menu.")) + return + self.setup_default_layouts(index_or_layout_id, settings) + else: + self.set_window_settings(*settings) + except cp.NoOptionError: + try: + layout = self.get_layout(index_or_layout_id) + layout.set_main_window_layout( + self.main, self.get_dockable_plugins()) + self.main.sig_layout_setup_ready.emit(layout) + except SpyderAPIError: + container.critical_message( + _("Warning"), + _("Quick switch layout #%s has not yet " + "been defined.") % str(index_or_layout_id)) + + # Make sure the flags are correctly set for visible panes + for plugin in self.get_dockable_plugins(): + try: + # New API + action = plugin.toggle_view_action + except AttributeError: + # Old API + action = plugin._toggle_view_action + action.setChecked(plugin.dockwidget.isVisible()) + + return index_or_layout_id + + def load_window_settings(self, prefix, default=False, section='main'): + """ + Load window layout settings from userconfig-based configuration with + *prefix*, under *section*. + + Parameters + ---------- + default: bool + if True, do not restore inner layout. + """ + 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 + else: + try: + hexstate = get_func(prefix + 'state', section=section) + except Exception: + hexstate = None + + pos = get_func(prefix + 'position', section=section) + + # It's necessary to verify if the window/position value is valid + # with the current screen. See spyder-ide/spyder#3748. + width = pos[0] + height = pos[1] + screen_shape = QApplication.desktop().geometry() + current_width = screen_shape.width() + current_height = screen_shape.height() + if current_width < width or current_height < height: + pos = self.get_conf_default(prefix + 'position', section) + + 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) + + def get_window_settings(self): + """ + Return current window settings. + + Symetric to the 'set_window_settings' setter. + """ + # FIXME: Window size in main window is update on resize + window_size = (self.window_size.width(), self.window_size.height()) + + is_fullscreen = self.main.isFullScreen() + if is_fullscreen: + is_maximized = self._maximized_flag + else: + 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) + + def set_window_settings(self, hexstate, window_size, prefs_dialog_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 + main.setWindowState(Qt.WindowNoState) + main.resize(self.window_size) + main.move(self.window_position) + + # Window layout + if hexstate: + hexstate_valid = self.main.restoreState( + QByteArray().fromHex(str(hexstate).encode('utf-8')), + version=WINDOW_STATE_VERSION + ) + + # Check layout validity. Spyder 4 and below use the version 0 + # state (default), whereas Spyder 5 will use version 1 state. + # For more info see the version argument for + # QMainWindow.restoreState: + # https://doc.qt.io/qt-5/qmainwindow.html#restoreState + if not hexstate_valid: + self.main.setUpdatesEnabled(True) + self.setup_layout(default=True) + return + + # Is fullscreen? + if is_fullscreen: + self.main.setWindowState(Qt.WindowFullScreen) + + # Is maximized? + if is_fullscreen: + self._maximized_flag = is_maximized + elif is_maximized: + self.main.setWindowState(Qt.WindowMaximized) + + self.main.setUpdatesEnabled(True) + + def save_current_window_settings(self, prefix, section='main', + none_state=False): + """ + Save current window settings. + + It saves config with *prefix* in the userconfig-based, + configuration under *section*. + """ + # Use current size and position when saving window settings. + # 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(), + section=section, + ) + self.set_conf( + prefix + 'is_fullscreen', + self.main.isFullScreen(), + section=section, + ) + self.set_conf( + prefix + 'position', + (pos.x(), pos.y()), + section=section, + ) + + self.maximize_dockwidget(restore=True) # Restore non-maximized layout + + if none_state: + self.set_conf( + prefix + 'state', + None, + section=section, + ) + else: + qba = self.main.saveState(version=WINDOW_STATE_VERSION) + self.set_conf( + prefix + 'state', + qbytearray_to_str(qba), + section=section, + ) + + self.set_conf( + prefix + 'statusbar', + not self.main.statusBar().isHidden(), + section=section, + ) + + @Slot() + def close_current_dockwidget(self): + """Search for the currently focused plugin and close it.""" + widget = QApplication.focusWidget() + for plugin in self.get_dockable_plugins(): + # TODO: remove old API + try: + # New API + if plugin.get_widget().isAncestorOf(widget): + plugin.toggle_view_action.setChecked(False) + break + except AttributeError: + # Old API + if plugin.isAncestorOf(widget): + plugin._toggle_view_action.setChecked(False) + break + + @property + def maximize_action(self): + """Expose maximize current dockwidget action.""" + return self.get_container()._maximize_dockwidget_action + + def maximize_dockwidget(self, restore=False): + """ + Maximize current dockwidget. + + Shortcut: Ctrl+Alt+Shift+M + First call: maximize current dockwidget + Second call (or restore=True): restore original window layout + """ + if self._state_before_maximizing is None: + if restore: + return + + # Select plugin to maximize + self._state_before_maximizing = self.main.saveState( + version=WINDOW_STATE_VERSION + ) + focus_widget = QApplication.focusWidget() + + for plugin in self.get_dockable_plugins(): + plugin.dockwidget.hide() + + try: + # New API + if plugin.get_widget().isAncestorOf(focus_widget): + self._last_plugin = plugin + except Exception: + # Old API + if plugin.isAncestorOf(focus_widget): + self._last_plugin = plugin + + # Only plugins that have a dockwidget are part of widgetlist, + # so last_plugin can be None after the above "for" cycle. + # For example, this happens if, after Spyder has started, focus + # is set to the Working directory toolbar (which doesn't have + # a dockwidget) and then you press the Maximize button + if self._last_plugin is None: + # Using the Editor as default plugin to maximize + self._last_plugin = self.get_plugin(Plugins.Editor) + + # Maximize last_plugin + self._last_plugin.dockwidget.toggleViewAction().setDisabled(True) + try: + # New API + self.main.setCentralWidget(self._last_plugin.get_widget()) + except AttributeError: + # Old API + self.main.setCentralWidget(self._last_plugin) + self._last_plugin._ismaximized = True + + # Workaround to solve an issue with editor's outline explorer: + # (otherwise the whole plugin is hidden and so is the outline + # explorer and the latter won't be refreshed if not visible) + try: + # New API + self._last_plugin.get_widget().show() + self._last_plugin.change_visibility(True) + except AttributeError: + # Old API + self._last_plugin.show() + self._last_plugin._visibility_changed(True) + + if self._last_plugin is self.main.editor: + # Automatically show the outline if the editor was maximized: + outline_explorer = self.get_plugin(Plugins.OutlineExplorer) + self.main.addDockWidget( + Qt.RightDockWidgetArea, + outline_explorer.dockwidget) + outline_explorer.dockwidget.show() + else: + # Restore original layout (before maximizing current dockwidget) + try: + # New API + self._last_plugin.dockwidget.setWidget( + self._last_plugin.get_widget()) + except AttributeError: + # Old API + self._last_plugin.dockwidget.setWidget(self._last_plugin) + self._last_plugin.dockwidget.toggleViewAction().setEnabled(True) + self.main.setCentralWidget(None) + + try: + # New API + self._last_plugin.get_widget().is_maximized = False + except AttributeError: + # Old API + self._last_plugin._ismaximized = False + + self.main.restoreState( + self._state_before_maximizing, version=WINDOW_STATE_VERSION + ) + self._state_before_maximizing = None + try: + # New API + self._last_plugin.get_widget().get_focus_widget().setFocus() + except AttributeError: + # Old API + self._last_plugin.get_focus_widget().setFocus() + + def _update_fullscreen_action(self): + if self._fullscreen_flag: + icon = self.create_icon('window_nofullscreen') + else: + icon = self.create_icon('window_fullscreen') + self.get_container()._fullscreen_action.setIcon(icon) + + @Slot() + def toggle_fullscreen(self): + """ + Toggle option to show the mainwindow in fullscreen or windowed. + """ + main = self.main + if self._fullscreen_flag: + self._fullscreen_flag = False + if os.name == 'nt': + main.setWindowFlags( + main.windowFlags() + ^ (Qt.FramelessWindowHint | Qt.WindowStaysOnTopHint)) + main.setGeometry(self._saved_normal_geometry) + main.showNormal() + if self._maximized_flag: + main.showMaximized() + else: + self._maximized_flag = main.isMaximized() + self._fullscreen_flag = True + self._saved_normal_geometry = main.normalGeometry() + if os.name == 'nt': + # Due to limitations of the Windows DWM, compositing is not + # handled correctly for OpenGL based windows when going into + # full screen mode, so we need to use this workaround. + # See spyder-ide/spyder#4291. + main.setWindowFlags(main.windowFlags() + | Qt.FramelessWindowHint + | Qt.WindowStaysOnTopHint) + + screen_number = QDesktopWidget().screenNumber(main) + if screen_number < 0: + screen_number = 0 + + r = QApplication.desktop().screenGeometry(screen_number) + main.setGeometry( + r.left() - 1, r.top() - 1, r.width() + 2, r.height() + 2) + main.showNormal() + else: + main.showFullScreen() + self._update_fullscreen_action() + + @property + def plugins_menu(self): + """Expose plugins toggle actions menu.""" + return self.get_container()._plugins_menu + + def create_plugins_menu(self): + """ + Populate panes menu with the toggle view action of each base plugin. + """ + order = ['editor', 'ipython_console', 'variable_explorer', + 'help', 'plots', None, 'explorer', 'outline_explorer', + 'project_explorer', 'find_in_files', None, 'historylog', + 'profiler', 'breakpoints', 'pylint', None, + 'onlinehelp', 'internal_console', None] + + for plugin in self.get_dockable_plugins(): + try: + # New API + action = plugin.toggle_view_action + except AttributeError: + # Old API + action = plugin._toggle_view_action + action.action_id = f'switch to {plugin.CONF_SECTION}' + + if action: + action.setChecked(plugin.dockwidget.isVisible()) + + try: + name = plugin.CONF_SECTION + pos = order.index(name) + except ValueError: + pos = None + + if pos is not None: + order[pos] = action + else: + order.append(action) + + actions = order[:] + for action in actions: + if type(action) is not str: + self.get_container()._plugins_menu.add_action(action) + + @property + def lock_interface_action(self): + return self.get_container()._lock_interface_action + + def _update_lock_interface_action(self): + """ + Helper method to update the locking of panes/dockwidgets and toolbars. + + Returns + ------- + None. + """ + if self._interface_locked: + icon = self.create_icon('drag_dock_widget') + text = _('Unlock panes and toolbars') + else: + icon = self.create_icon('lock') + text = _('Lock panes and toolbars') + self.lock_interface_action.setIcon(icon) + self.lock_interface_action.setText(text) + + def toggle_lock(self, value=None): + """Lock/Unlock dockwidgets and toolbars.""" + self._interface_locked = ( + not self._interface_locked if value is None else value) + self.set_conf('panes_locked', self._interface_locked, 'main') + self._update_lock_interface_action() + # Apply lock to panes + for plugin in self.get_dockable_plugins(): + if self._interface_locked: + if plugin.dockwidget.isFloating(): + plugin.dockwidget.setFloating(False) + + plugin.dockwidget.remove_title_bar() + else: + plugin.dockwidget.set_title_bar() + + # Apply lock to toolbars + toolbar = self.get_plugin(Plugins.Toolbar) + if toolbar: + toolbar.toggle_lock(value=self._interface_locked) diff --git a/spyder/plugins/layout/widgets/dialog.py b/spyder/plugins/layout/widgets/dialog.py index fc626c8a225..59b66eb835a 100644 --- a/spyder/plugins/layout/widgets/dialog.py +++ b/spyder/plugins/layout/widgets/dialog.py @@ -1,395 +1,395 @@ -# -*- coding: utf-8 -*- -# -# Copyright © Spyder Project Contributors -# Licensed under the terms of the MIT License -# (see spyder/__init__.py for details) - -"""Layout dialogs""" - -# Standard library imports -import sys - -# Third party imports -from qtpy.QtCore import QAbstractTableModel, QModelIndex, QSize, Qt -from qtpy.compat import from_qvariant, to_qvariant -from qtpy.QtWidgets import (QAbstractItemView, QComboBox, QDialog, - QDialogButtonBox, QGroupBox, QHBoxLayout, - QPushButton, QTableView, QVBoxLayout) - -# Local imports -from spyder.config.base import _ -from spyder.py3compat import to_text_string - - -class LayoutModel(QAbstractTableModel): - """ """ - def __init__(self, parent, names, ui_names, order, active, read_only): - super(LayoutModel, self).__init__(parent) - - # variables - self._parent = parent - self.names = names - self.ui_names = ui_names - self.order = order - self.active = active - self.read_only = read_only - self._rows = [] - self.set_data(names, ui_names, order, active, read_only) - - def set_data(self, names, ui_names, order, active, read_only): - """ """ - self._rows = [] - self.names = names - self.ui_names = ui_names - self.order = order - self.active = active - self.read_only = read_only - for name in order: - index = names.index(name) - if name in active: - row = [ui_names[index], name, True] - else: - row = [ui_names[index], name, False] - self._rows.append(row) - - def flags(self, index): - """Override Qt method""" - row = index.row() - ui_name, name, state = self.row(row) - - if name in self.read_only: - return Qt.NoItemFlags - if not index.isValid(): - return Qt.ItemIsEnabled - column = index.column() - if column in [0]: - return Qt.ItemFlags(int(Qt.ItemIsEnabled | Qt.ItemIsSelectable | - Qt.ItemIsUserCheckable | - Qt.ItemIsEditable)) - else: - return Qt.ItemFlags(Qt.ItemIsEnabled) - - def data(self, index, role=Qt.DisplayRole): - """Override Qt method""" - if not index.isValid() or not 0 <= index.row() < len(self._rows): - return to_qvariant() - row = index.row() - column = index.column() - - ui_name, name, state = self.row(row) - - if role == Qt.DisplayRole or role == Qt.EditRole: - if column == 0: - return to_qvariant(ui_name) - elif role == Qt.UserRole: - if column == 0: - return to_qvariant(name) - elif role == Qt.CheckStateRole: - if column == 0: - if state: - return Qt.Checked - else: - return Qt.Unchecked - if column == 1: - return to_qvariant(state) - return to_qvariant() - - def setData(self, index, value, role): - """Override Qt method""" - row = index.row() - ui_name, name, state = self.row(row) - - if role == Qt.CheckStateRole: - self.set_row(row, [ui_name, name, not state]) - self._parent.setCurrentIndex(index) - self._parent.setFocus() - self.dataChanged.emit(index, index) - return True - elif role == Qt.EditRole: - self.set_row( - row, [from_qvariant(value, to_text_string), name, state]) - self.dataChanged.emit(index, index) - return True - return True - - def rowCount(self, index=QModelIndex()): - """Override Qt method""" - return len(self._rows) - - def columnCount(self, index=QModelIndex()): - """Override Qt method""" - return 2 - - def row(self, rownum): - """ """ - if self._rows == [] or rownum >= len(self._rows): - return [None, None, None] - else: - return self._rows[rownum] - - def set_row(self, rownum, value): - """ """ - self._rows[rownum] = value - - -class LayoutSaveDialog(QDialog): - """ """ - def __init__(self, parent, order): - super(LayoutSaveDialog, self).__init__(parent) - - # variables - self._parent = parent - - # widgets - self.combo_box = QComboBox(self) - self.combo_box.addItems(order) - self.combo_box.setEditable(True) - self.combo_box.clearEditText() - self.button_box = QDialogButtonBox(QDialogButtonBox.Ok | - QDialogButtonBox.Cancel, - Qt.Horizontal, self) - self.button_ok = self.button_box.button(QDialogButtonBox.Ok) - self.button_cancel = self.button_box.button(QDialogButtonBox.Cancel) - - # widget setup - self.button_ok.setEnabled(False) - self.dialog_size = QSize(300, 100) - self.setWindowTitle('Save layout as') - self.setModal(True) - self.setMinimumSize(self.dialog_size) - self.setFixedSize(self.dialog_size) - - # layouts - self.layout = QVBoxLayout() - self.layout.addWidget(self.combo_box) - self.layout.addWidget(self.button_box) - self.setLayout(self.layout) - - # signals and slots - self.button_box.accepted.connect(self.accept) - self.button_box.rejected.connect(self.close) - self.combo_box.editTextChanged.connect(self.check_text) - - def check_text(self, text): - """Disable empty layout name possibility""" - if to_text_string(text) == u'': - self.button_ok.setEnabled(False) - else: - self.button_ok.setEnabled(True) - - -class LayoutSettingsDialog(QDialog): - """Layout settings dialog""" - def __init__(self, parent, names, ui_names, order, active, read_only): - super(LayoutSettingsDialog, self).__init__(parent) - # variables - self._parent = parent - self._selection_model = None - self.names = names - self.ui_names = ui_names - self.order = order - self.active = active - self.read_only = read_only - - # widgets - self.button_move_up = QPushButton(_('Move Up')) - self.button_move_down = QPushButton(_('Move Down')) - self.button_delete = QPushButton(_('Delete Layout')) - self.button_box = QDialogButtonBox(QDialogButtonBox.Ok | - QDialogButtonBox.Cancel, - Qt.Horizontal, self) - self.group_box = QGroupBox(_("Layout Display and Order")) - self.table = QTableView(self) - self.ok_button = self.button_box.button(QDialogButtonBox.Ok) - self.cancel_button = self.button_box.button(QDialogButtonBox.Cancel) - self.cancel_button.setDefault(True) - self.cancel_button.setAutoDefault(True) - - # widget setup - self.dialog_size = QSize(300, 200) - self.setMinimumSize(self.dialog_size) - self.setFixedSize(self.dialog_size) - self.setWindowTitle('Layout Settings') - - self.table.setModel( - LayoutModel(self.table, names, ui_names, order, active, read_only)) - self.table.setSelectionBehavior(QAbstractItemView.SelectRows) - self.table.setSelectionMode(QAbstractItemView.SingleSelection) - self.table.verticalHeader().hide() - self.table.horizontalHeader().hide() - self.table.setAlternatingRowColors(True) - self.table.setShowGrid(False) - self.table.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) - self.table.horizontalHeader().setStretchLastSection(True) - self.table.setColumnHidden(1, True) - - # need to keep a reference for pyside not to segfault! - self._selection_model = self.table.selectionModel() - - # layout - buttons_layout = QVBoxLayout() - buttons_layout.addWidget(self.button_move_up) - buttons_layout.addWidget(self.button_move_down) - buttons_layout.addStretch() - buttons_layout.addWidget(self.button_delete) - - group_layout = QHBoxLayout() - group_layout.addWidget(self.table) - group_layout.addLayout(buttons_layout) - self.group_box.setLayout(group_layout) - - layout = QVBoxLayout() - layout.addWidget(self.group_box) - layout.addWidget(self.button_box) - - self.setLayout(layout) - - # signals and slots - self.button_box.accepted.connect(self.accept) - self.button_box.rejected.connect(self.close) - self.button_delete.clicked.connect(self.delete_layout) - self.button_move_up.clicked.connect(lambda: self.move_layout(True)) - self.button_move_down.clicked.connect(lambda: self.move_layout(False)) - self.table.model().dataChanged.connect( - lambda: self.selection_changed(None, None)) - self._selection_model.selectionChanged.connect( - lambda: self.selection_changed(None, None)) - - # focus table - if len(names) > len(read_only): - row = len(read_only) - index = self.table.model().index(row, 0) - self.table.setCurrentIndex(index) - self.table.setFocus() - else: - # initial button state in case only programmatic layouts - # are available - self.button_move_up.setDisabled(True) - self.button_move_down.setDisabled(True) - self.button_delete.setDisabled(True) - - def delete_layout(self): - """Delete layout from the config.""" - names, ui_names, order, active, read_only = ( - self.names, self.ui_names, self.order, self.active, self.read_only) - row = self.table.selectionModel().currentIndex().row() - ui_name, name, state = self.table.model().row(row) - - if name not in read_only: - name = from_qvariant( - self.table.selectionModel().currentIndex().data(), - to_text_string) - if ui_name in ui_names: - index = ui_names.index(ui_name) - else: - # In case nothing has focus in the table - return - if index != -1: - order.remove(ui_name) - names.remove(ui_name) - ui_names.remove(ui_name) - if name in active: - active.remove(ui_name) - self.names, self.ui_names, self.order, self.active = ( - names, ui_names, order, active) - self.table.model().set_data( - names, ui_names, order, active, read_only) - index = self.table.model().index(0, 0) - self.table.setCurrentIndex(index) - self.table.setFocus() - self.selection_changed(None, None) - if len(order) == 0 or len(names) == len(read_only): - self.button_move_up.setDisabled(True) - self.button_move_down.setDisabled(True) - self.button_delete.setDisabled(True) - - def move_layout(self, up=True): - """ """ - names, ui_names, order, active, read_only = ( - self.names, self.ui_names, self.order, self.active, self.read_only) - row = self.table.selectionModel().currentIndex().row() - row_new = row - _ui_name, name, _state = self.table.model().row(row) - - if name not in read_only: - if up: - row_new -= 1 - else: - row_new += 1 - - if order[row_new] not in read_only: - order[row], order[row_new] = order[row_new], order[row] - - self.order = order - self.table.model().set_data( - names, ui_names, order, active, read_only) - index = self.table.model().index(row_new, 0) - self.table.setCurrentIndex(index) - self.table.setFocus() - self.selection_changed(None, None) - - def selection_changed(self, selection, deselection): - """ """ - model = self.table.model() - index = self.table.currentIndex() - row = index.row() - order, names, ui_names, active, read_only = ( - self.order, self.names, self.ui_names, self.active, self.read_only) - - state = model.row(row)[2] - ui_name = model.row(row)[0] - - # Check if name changed - if ui_name not in ui_names: # Did changed - # row == -1, means no items left to delete - if row != -1 and len(names) > len(read_only): - old_name = order[row] - order[row] = ui_name - names[names.index(old_name)] = ui_name - ui_names = names - if old_name in active: - active[active.index(old_name)] = ui_name - - # Check if checkbox clicked - if state: - if ui_name not in active: - active.append(ui_name) - else: - if ui_name in active: - active.remove(ui_name) - - self.active = active - self.order = order - self.names = names - self.ui_names = ui_names - self.button_move_up.setDisabled(False) - self.button_move_down.setDisabled(False) - - if row == 0: - self.button_move_up.setDisabled(True) - if row == len(names) - 1: - self.button_move_down.setDisabled(True) - if len(names) == 0: - self.button_move_up.setDisabled(True) - self.button_move_down.setDisabled(True) - - -def test(): - """Run layout test widget test""" - from spyder.utils.qthelpers import qapplication - - app = qapplication() - names = ['test', 'tester', '20', '30', '40'] - ui_names = ['L1', 'L2', '20', '30', '40'] - order = ['test', 'tester', '20', '30', '40'] - read_only = ['test', 'tester'] - active = ['test', 'tester'] - widget_1 = LayoutSettingsDialog( - None, names, ui_names, order, active, read_only) - widget_2 = LayoutSaveDialog(None, order) - widget_1.show() - widget_2.show() - sys.exit(app.exec_()) - -if __name__ == '__main__': - test() +# -*- coding: utf-8 -*- +# +# Copyright © Spyder Project Contributors +# Licensed under the terms of the MIT License +# (see spyder/__init__.py for details) + +"""Layout dialogs""" + +# Standard library imports +import sys + +# Third party imports +from qtpy.QtCore import QAbstractTableModel, QModelIndex, QSize, Qt +from qtpy.compat import from_qvariant, to_qvariant +from qtpy.QtWidgets import (QAbstractItemView, QComboBox, QDialog, + QDialogButtonBox, QGroupBox, QHBoxLayout, + QPushButton, QTableView, QVBoxLayout) + +# Local imports +from spyder.config.base import _ +from spyder.py3compat import to_text_string + + +class LayoutModel(QAbstractTableModel): + """ """ + def __init__(self, parent, names, ui_names, order, active, read_only): + super(LayoutModel, self).__init__(parent) + + # variables + self._parent = parent + self.names = names + self.ui_names = ui_names + self.order = order + self.active = active + self.read_only = read_only + self._rows = [] + self.set_data(names, ui_names, order, active, read_only) + + def set_data(self, names, ui_names, order, active, read_only): + """ """ + self._rows = [] + self.names = names + self.ui_names = ui_names + self.order = order + self.active = active + self.read_only = read_only + for name in order: + index = names.index(name) + if name in active: + row = [ui_names[index], name, True] + else: + row = [ui_names[index], name, False] + self._rows.append(row) + + def flags(self, index): + """Override Qt method""" + row = index.row() + ui_name, name, state = self.row(row) + + if name in self.read_only: + return Qt.NoItemFlags + if not index.isValid(): + return Qt.ItemIsEnabled + column = index.column() + if column in [0]: + return Qt.ItemFlags(int(Qt.ItemIsEnabled | Qt.ItemIsSelectable | + Qt.ItemIsUserCheckable | + Qt.ItemIsEditable)) + else: + return Qt.ItemFlags(Qt.ItemIsEnabled) + + def data(self, index, role=Qt.DisplayRole): + """Override Qt method""" + if not index.isValid() or not 0 <= index.row() < len(self._rows): + return to_qvariant() + row = index.row() + column = index.column() + + ui_name, name, state = self.row(row) + + if role == Qt.DisplayRole or role == Qt.EditRole: + if column == 0: + return to_qvariant(ui_name) + elif role == Qt.UserRole: + if column == 0: + return to_qvariant(name) + elif role == Qt.CheckStateRole: + if column == 0: + if state: + return Qt.Checked + else: + return Qt.Unchecked + if column == 1: + return to_qvariant(state) + return to_qvariant() + + def setData(self, index, value, role): + """Override Qt method""" + row = index.row() + ui_name, name, state = self.row(row) + + if role == Qt.CheckStateRole: + self.set_row(row, [ui_name, name, not state]) + self._parent.setCurrentIndex(index) + self._parent.setFocus() + self.dataChanged.emit(index, index) + return True + elif role == Qt.EditRole: + self.set_row( + row, [from_qvariant(value, to_text_string), name, state]) + self.dataChanged.emit(index, index) + return True + return True + + def rowCount(self, index=QModelIndex()): + """Override Qt method""" + return len(self._rows) + + def columnCount(self, index=QModelIndex()): + """Override Qt method""" + return 2 + + def row(self, rownum): + """ """ + if self._rows == [] or rownum >= len(self._rows): + return [None, None, None] + else: + return self._rows[rownum] + + def set_row(self, rownum, value): + """ """ + self._rows[rownum] = value + + +class LayoutSaveDialog(QDialog): + """ """ + def __init__(self, parent, order): + super(LayoutSaveDialog, self).__init__(parent) + + # variables + self._parent = parent + + # widgets + self.combo_box = QComboBox(self) + self.combo_box.addItems(order) + self.combo_box.setEditable(True) + self.combo_box.clearEditText() + self.button_box = QDialogButtonBox(QDialogButtonBox.Ok | + QDialogButtonBox.Cancel, + Qt.Horizontal, self) + self.button_ok = self.button_box.button(QDialogButtonBox.Ok) + self.button_cancel = self.button_box.button(QDialogButtonBox.Cancel) + + # widget setup + self.button_ok.setEnabled(False) + self.dialog_size = QSize(300, 100) + self.setWindowTitle('Save layout as') + self.setModal(True) + self.setMinimumSize(self.dialog_size) + self.setFixedSize(self.dialog_size) + + # layouts + self.layout = QVBoxLayout() + self.layout.addWidget(self.combo_box) + self.layout.addWidget(self.button_box) + self.setLayout(self.layout) + + # signals and slots + self.button_box.accepted.connect(self.accept) + self.button_box.rejected.connect(self.close) + self.combo_box.editTextChanged.connect(self.check_text) + + def check_text(self, text): + """Disable empty layout name possibility""" + if to_text_string(text) == u'': + self.button_ok.setEnabled(False) + else: + self.button_ok.setEnabled(True) + + +class LayoutSettingsDialog(QDialog): + """Layout settings dialog""" + def __init__(self, parent, names, ui_names, order, active, read_only): + super(LayoutSettingsDialog, self).__init__(parent) + # variables + self._parent = parent + self._selection_model = None + self.names = names + self.ui_names = ui_names + self.order = order + self.active = active + self.read_only = read_only + + # widgets + self.button_move_up = QPushButton(_('Move Up')) + self.button_move_down = QPushButton(_('Move Down')) + self.button_delete = QPushButton(_('Delete Layout')) + self.button_box = QDialogButtonBox(QDialogButtonBox.Ok | + QDialogButtonBox.Cancel, + Qt.Horizontal, self) + self.group_box = QGroupBox(_("Layout Display and Order")) + self.table = QTableView(self) + self.ok_button = self.button_box.button(QDialogButtonBox.Ok) + self.cancel_button = self.button_box.button(QDialogButtonBox.Cancel) + self.cancel_button.setDefault(True) + self.cancel_button.setAutoDefault(True) + + # widget setup + self.dialog_size = QSize(300, 200) + self.setMinimumSize(self.dialog_size) + self.setFixedSize(self.dialog_size) + self.setWindowTitle('Layout Settings') + + self.table.setModel( + LayoutModel(self.table, names, ui_names, order, active, read_only)) + self.table.setSelectionBehavior(QAbstractItemView.SelectRows) + self.table.setSelectionMode(QAbstractItemView.SingleSelection) + self.table.verticalHeader().hide() + self.table.horizontalHeader().hide() + self.table.setAlternatingRowColors(True) + self.table.setShowGrid(False) + self.table.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) + self.table.horizontalHeader().setStretchLastSection(True) + self.table.setColumnHidden(1, True) + + # need to keep a reference for pyside not to segfault! + self._selection_model = self.table.selectionModel() + + # layout + buttons_layout = QVBoxLayout() + buttons_layout.addWidget(self.button_move_up) + buttons_layout.addWidget(self.button_move_down) + buttons_layout.addStretch() + buttons_layout.addWidget(self.button_delete) + + group_layout = QHBoxLayout() + group_layout.addWidget(self.table) + group_layout.addLayout(buttons_layout) + self.group_box.setLayout(group_layout) + + layout = QVBoxLayout() + layout.addWidget(self.group_box) + layout.addWidget(self.button_box) + + self.setLayout(layout) + + # signals and slots + self.button_box.accepted.connect(self.accept) + self.button_box.rejected.connect(self.close) + self.button_delete.clicked.connect(self.delete_layout) + self.button_move_up.clicked.connect(lambda: self.move_layout(True)) + self.button_move_down.clicked.connect(lambda: self.move_layout(False)) + self.table.model().dataChanged.connect( + lambda: self.selection_changed(None, None)) + self._selection_model.selectionChanged.connect( + lambda: self.selection_changed(None, None)) + + # focus table + if len(names) > len(read_only): + row = len(read_only) + index = self.table.model().index(row, 0) + self.table.setCurrentIndex(index) + self.table.setFocus() + else: + # initial button state in case only programmatic layouts + # are available + self.button_move_up.setDisabled(True) + self.button_move_down.setDisabled(True) + self.button_delete.setDisabled(True) + + def delete_layout(self): + """Delete layout from the config.""" + names, ui_names, order, active, read_only = ( + self.names, self.ui_names, self.order, self.active, self.read_only) + row = self.table.selectionModel().currentIndex().row() + ui_name, name, state = self.table.model().row(row) + + if name not in read_only: + name = from_qvariant( + self.table.selectionModel().currentIndex().data(), + to_text_string) + if ui_name in ui_names: + index = ui_names.index(ui_name) + else: + # In case nothing has focus in the table + return + if index != -1: + order.remove(ui_name) + names.remove(ui_name) + ui_names.remove(ui_name) + if name in active: + active.remove(ui_name) + self.names, self.ui_names, self.order, self.active = ( + names, ui_names, order, active) + self.table.model().set_data( + names, ui_names, order, active, read_only) + index = self.table.model().index(0, 0) + self.table.setCurrentIndex(index) + self.table.setFocus() + self.selection_changed(None, None) + if len(order) == 0 or len(names) == len(read_only): + self.button_move_up.setDisabled(True) + self.button_move_down.setDisabled(True) + self.button_delete.setDisabled(True) + + def move_layout(self, up=True): + """ """ + names, ui_names, order, active, read_only = ( + self.names, self.ui_names, self.order, self.active, self.read_only) + row = self.table.selectionModel().currentIndex().row() + row_new = row + _ui_name, name, _state = self.table.model().row(row) + + if name not in read_only: + if up: + row_new -= 1 + else: + row_new += 1 + + if order[row_new] not in read_only: + order[row], order[row_new] = order[row_new], order[row] + + self.order = order + self.table.model().set_data( + names, ui_names, order, active, read_only) + index = self.table.model().index(row_new, 0) + self.table.setCurrentIndex(index) + self.table.setFocus() + self.selection_changed(None, None) + + def selection_changed(self, selection, deselection): + """ """ + model = self.table.model() + index = self.table.currentIndex() + row = index.row() + order, names, ui_names, active, read_only = ( + self.order, self.names, self.ui_names, self.active, self.read_only) + + state = model.row(row)[2] + ui_name = model.row(row)[0] + + # Check if name changed + if ui_name not in ui_names: # Did changed + # row == -1, means no items left to delete + if row != -1 and len(names) > len(read_only): + old_name = order[row] + order[row] = ui_name + names[names.index(old_name)] = ui_name + ui_names = names + if old_name in active: + active[active.index(old_name)] = ui_name + + # Check if checkbox clicked + if state: + if ui_name not in active: + active.append(ui_name) + else: + if ui_name in active: + active.remove(ui_name) + + self.active = active + self.order = order + self.names = names + self.ui_names = ui_names + self.button_move_up.setDisabled(False) + self.button_move_down.setDisabled(False) + + if row == 0: + self.button_move_up.setDisabled(True) + if row == len(names) - 1: + self.button_move_down.setDisabled(True) + if len(names) == 0: + self.button_move_up.setDisabled(True) + self.button_move_down.setDisabled(True) + + +def test(): + """Run layout test widget test""" + from spyder.utils.qthelpers import qapplication + + app = qapplication() + names = ['test', 'tester', '20', '30', '40'] + ui_names = ['L1', 'L2', '20', '30', '40'] + order = ['test', 'tester', '20', '30', '40'] + read_only = ['test', 'tester'] + active = ['test', 'tester'] + widget_1 = LayoutSettingsDialog( + None, names, ui_names, order, active, read_only) + widget_2 = LayoutSaveDialog(None, order) + widget_1.show() + widget_2.show() + sys.exit(app.exec_()) + +if __name__ == '__main__': + test() diff --git a/spyder/plugins/maininterpreter/confpage.py b/spyder/plugins/maininterpreter/confpage.py index 3f1f9f7893c..0f4c3ff795d 100644 --- a/spyder/plugins/maininterpreter/confpage.py +++ b/spyder/plugins/maininterpreter/confpage.py @@ -1,276 +1,276 @@ -# -*- coding: utf-8 -*- -# -# Copyright © Spyder Project Contributors -# Licensed under the terms of the MIT License -# (see spyder/__init__.py for details) - -"""Main interpreter entry in Preferences.""" - -# Standard library imports -import os -import os.path as osp -import sys - -# Third party imports -from qtpy.QtWidgets import (QButtonGroup, QGroupBox, QInputDialog, QLabel, - QLineEdit, QMessageBox, QPushButton, QVBoxLayout) - -# Local imports -from spyder.api.translations import get_translation -from spyder.api.preferences import PluginConfigPage -from spyder.py3compat import PY2, to_text_string -from spyder.utils import programs -from spyder.utils.conda import get_list_conda_envs_cache -from spyder.utils.misc import get_python_executable -from spyder.utils.pyenv import get_list_pyenv_envs_cache - -# Localization -_ = get_translation('spyder') - - -class MainInterpreterConfigPage(PluginConfigPage): - - def __init__(self, plugin, parent): - super().__init__(plugin, parent) - self.apply_callback = self.perform_adjustments - - self.cus_exec_radio = None - self.pyexec_edit = None - self.cus_exec_combo = None - - conda_env = get_list_conda_envs_cache() - pyenv_env = get_list_pyenv_envs_cache() - envs = {**conda_env, **pyenv_env} - valid_custom_list = self.get_option('custom_interpreters_list') - for env in envs.keys(): - path, _ = envs[env] - if path not in valid_custom_list: - valid_custom_list.append(path) - self.set_option('custom_interpreters_list', valid_custom_list) - - # add custom_interpreter to executable selection - executable = self.get_option('executable') - - # check if the executable is valid - use Spyder's if not - if self.get_option('default') or not osp.isfile(executable): - executable = get_python_executable() - elif not self.get_option('custom_interpreter'): - self.set_option('custom_interpreter', ' ') - - plugin._add_to_custom_interpreters(executable) - self.validate_custom_interpreters_list() - - def initialize(self): - super().initialize() - - def setup_page(self): - newcb = self.create_checkbox - - # Python executable Group - pyexec_group = QGroupBox(_("Python interpreter")) - pyexec_bg = QButtonGroup(pyexec_group) - pyexec_label = QLabel(_("Select the Python interpreter for all Spyder " - "consoles")) - self.def_exec_radio = self.create_radiobutton( - _("Default (i.e. the same as Spyder's)"), - 'default', - button_group=pyexec_bg, - ) - self.cus_exec_radio = self.create_radiobutton( - _("Use the following Python interpreter:"), - 'custom', - button_group=pyexec_bg, - ) - - if os.name == 'nt': - filters = _("Executables")+" (*.exe)" - else: - filters = None - - pyexec_layout = QVBoxLayout() - pyexec_layout.addWidget(pyexec_label) - pyexec_layout.addWidget(self.def_exec_radio) - pyexec_layout.addWidget(self.cus_exec_radio) - self.validate_custom_interpreters_list() - self.cus_exec_combo = self.create_file_combobox( - _('Recent custom interpreters'), - self.get_option('custom_interpreters_list'), - 'custom_interpreter', - filters=filters, - default_line_edit=True, - adjust_to_contents=True, - validate_callback=programs.is_python_interpreter, - ) - self.def_exec_radio.toggled.connect(self.cus_exec_combo.setDisabled) - self.cus_exec_radio.toggled.connect(self.cus_exec_combo.setEnabled) - pyexec_layout.addWidget(self.cus_exec_combo) - pyexec_group.setLayout(pyexec_layout) - - self.pyexec_edit = self.cus_exec_combo.combobox.lineEdit() - - # UMR Group - umr_group = QGroupBox(_("User Module Reloader (UMR)")) - umr_label = QLabel(_("UMR forces Python to reload modules which were " - "imported when executing a file in a Python or " - "IPython console with the runfile " - "function.")) - umr_label.setWordWrap(True) - umr_enabled_box = newcb( - _("Enable UMR"), - 'umr/enabled', - msg_if_enabled=True, - msg_warning=_( - "This option will enable the User Module Reloader (UMR) " - "in Python/IPython consoles. UMR forces Python to " - "reload deeply modules during import when running a " - "Python file using the Spyder's builtin function " - "runfile." - "

1. UMR may require to restart the " - "console in which it will be called " - "(otherwise only newly imported modules will be " - "reloaded when executing files)." - "

2. If errors occur when re-running a " - "PyQt-based program, please check that the Qt objects " - "are properly destroyed (e.g. you may have to use the " - "attribute Qt.WA_DeleteOnClose on your main " - "window, using the setAttribute method)" - ), - ) - umr_verbose_box = newcb( - _("Show reloaded modules list"), - 'umr/verbose', - msg_info=_("Please note that these changes will " - "be applied only to new consoles"), - ) - umr_namelist_btn = QPushButton( - _("Set UMR excluded (not reloaded) modules")) - umr_namelist_btn.clicked.connect(self.set_umr_namelist) - - umr_layout = QVBoxLayout() - umr_layout.addWidget(umr_label) - umr_layout.addWidget(umr_enabled_box) - umr_layout.addWidget(umr_verbose_box) - umr_layout.addWidget(umr_namelist_btn) - umr_group.setLayout(umr_layout) - - vlayout = QVBoxLayout() - vlayout.addWidget(pyexec_group) - vlayout.addWidget(umr_group) - vlayout.addStretch(1) - self.setLayout(vlayout) - - def warn_python_compatibility(self, pyexec): - if not osp.isfile(pyexec): - return - - spyder_version = sys.version_info[0] - try: - args = ["-c", "import sys; print(sys.version_info[0])"] - proc = programs.run_program(pyexec, args, env={}) - console_version = int(proc.communicate()[0]) - except IOError: - console_version = spyder_version - except ValueError: - return False - - if spyder_version != console_version: - QMessageBox.warning( - self, - _('Warning'), - _("You selected a Python %d interpreter for the console " - "but Spyder is running on Python %d!.

" - "Although this is possible, we recommend you to install and " - "run Spyder directly with your selected interpreter, to avoid " - "seeing false warnings and errors due to the incompatible " - "syntax between these two Python versions." - ) % (console_version, spyder_version), - QMessageBox.Ok, - ) - - return True - - def set_umr_namelist(self): - """Set UMR excluded modules name list""" - arguments, valid = QInputDialog.getText( - self, - _('UMR'), - _("Set the list of excluded modules as this: " - "numpy, scipy"), - QLineEdit.Normal, - ", ".join(self.get_option('umr/namelist')), - ) - if valid: - arguments = to_text_string(arguments) - if arguments: - namelist = arguments.replace(' ', '').split(',') - fixed_namelist = [] - non_ascii_namelist = [] - for module_name in namelist: - if PY2: - if all(ord(c) < 128 for c in module_name): - if programs.is_module_installed(module_name): - fixed_namelist.append(module_name) - else: - QMessageBox.warning( - self, - _('Warning'), - _("You are working with Python 2, this means " - "that you can not import a module that " - "contains non-ascii characters."), - QMessageBox.Ok, - ) - non_ascii_namelist.append(module_name) - elif programs.is_module_installed(module_name): - fixed_namelist.append(module_name) - - invalid = ", ".join(set(namelist)-set(fixed_namelist)- - set(non_ascii_namelist)) - if invalid: - QMessageBox.warning( - self, - _('UMR'), - _("The following modules are not " - "installed on your machine:\n%s") % invalid, - QMessageBox.Ok, - ) - QMessageBox.information( - self, - _('UMR'), - _("Please note that these changes will " - "be applied only to new IPython consoles"), - QMessageBox.Ok, - ) - else: - fixed_namelist = [] - - self.set_option('umr/namelist', fixed_namelist) - - def validate_custom_interpreters_list(self): - """Check that the used custom interpreters are still valid.""" - custom_list = self.get_option('custom_interpreters_list') - valid_custom_list = [] - for value in custom_list: - if osp.isfile(value): - valid_custom_list.append(value) - - self.set_option('custom_interpreters_list', valid_custom_list) - - def perform_adjustments(self): - """Perform some adjustments to the page after applying preferences.""" - if not self.def_exec_radio.isChecked(): - # Get current executable - executable = self.pyexec_edit.text() - executable = osp.normpath(executable) - if executable.endswith('pythonw.exe'): - executable = executable.replace("pythonw.exe", "python.exe") - - # Update combobox items. - custom_list = self.cus_exec_combo.combobox.choices - if executable not in custom_list: - custom_list = custom_list + [executable] - self.cus_exec_combo.combobox.clear() - self.cus_exec_combo.combobox.addItems(custom_list) - self.pyexec_edit.setText(executable) - - # Show warning compatibility message. - self.warn_python_compatibility(executable) +# -*- coding: utf-8 -*- +# +# Copyright © Spyder Project Contributors +# Licensed under the terms of the MIT License +# (see spyder/__init__.py for details) + +"""Main interpreter entry in Preferences.""" + +# Standard library imports +import os +import os.path as osp +import sys + +# Third party imports +from qtpy.QtWidgets import (QButtonGroup, QGroupBox, QInputDialog, QLabel, + QLineEdit, QMessageBox, QPushButton, QVBoxLayout) + +# Local imports +from spyder.api.translations import get_translation +from spyder.api.preferences import PluginConfigPage +from spyder.py3compat import PY2, to_text_string +from spyder.utils import programs +from spyder.utils.conda import get_list_conda_envs_cache +from spyder.utils.misc import get_python_executable +from spyder.utils.pyenv import get_list_pyenv_envs_cache + +# Localization +_ = get_translation('spyder') + + +class MainInterpreterConfigPage(PluginConfigPage): + + def __init__(self, plugin, parent): + super().__init__(plugin, parent) + self.apply_callback = self.perform_adjustments + + self.cus_exec_radio = None + self.pyexec_edit = None + self.cus_exec_combo = None + + conda_env = get_list_conda_envs_cache() + pyenv_env = get_list_pyenv_envs_cache() + envs = {**conda_env, **pyenv_env} + valid_custom_list = self.get_option('custom_interpreters_list') + for env in envs.keys(): + path, _ = envs[env] + if path not in valid_custom_list: + valid_custom_list.append(path) + self.set_option('custom_interpreters_list', valid_custom_list) + + # add custom_interpreter to executable selection + executable = self.get_option('executable') + + # check if the executable is valid - use Spyder's if not + if self.get_option('default') or not osp.isfile(executable): + executable = get_python_executable() + elif not self.get_option('custom_interpreter'): + self.set_option('custom_interpreter', ' ') + + plugin._add_to_custom_interpreters(executable) + self.validate_custom_interpreters_list() + + def initialize(self): + super().initialize() + + def setup_page(self): + newcb = self.create_checkbox + + # Python executable Group + pyexec_group = QGroupBox(_("Python interpreter")) + pyexec_bg = QButtonGroup(pyexec_group) + pyexec_label = QLabel(_("Select the Python interpreter for all Spyder " + "consoles")) + self.def_exec_radio = self.create_radiobutton( + _("Default (i.e. the same as Spyder's)"), + 'default', + button_group=pyexec_bg, + ) + self.cus_exec_radio = self.create_radiobutton( + _("Use the following Python interpreter:"), + 'custom', + button_group=pyexec_bg, + ) + + if os.name == 'nt': + filters = _("Executables")+" (*.exe)" + else: + filters = None + + pyexec_layout = QVBoxLayout() + pyexec_layout.addWidget(pyexec_label) + pyexec_layout.addWidget(self.def_exec_radio) + pyexec_layout.addWidget(self.cus_exec_radio) + self.validate_custom_interpreters_list() + self.cus_exec_combo = self.create_file_combobox( + _('Recent custom interpreters'), + self.get_option('custom_interpreters_list'), + 'custom_interpreter', + filters=filters, + default_line_edit=True, + adjust_to_contents=True, + validate_callback=programs.is_python_interpreter, + ) + self.def_exec_radio.toggled.connect(self.cus_exec_combo.setDisabled) + self.cus_exec_radio.toggled.connect(self.cus_exec_combo.setEnabled) + pyexec_layout.addWidget(self.cus_exec_combo) + pyexec_group.setLayout(pyexec_layout) + + self.pyexec_edit = self.cus_exec_combo.combobox.lineEdit() + + # UMR Group + umr_group = QGroupBox(_("User Module Reloader (UMR)")) + umr_label = QLabel(_("UMR forces Python to reload modules which were " + "imported when executing a file in a Python or " + "IPython console with the runfile " + "function.")) + umr_label.setWordWrap(True) + umr_enabled_box = newcb( + _("Enable UMR"), + 'umr/enabled', + msg_if_enabled=True, + msg_warning=_( + "This option will enable the User Module Reloader (UMR) " + "in Python/IPython consoles. UMR forces Python to " + "reload deeply modules during import when running a " + "Python file using the Spyder's builtin function " + "runfile." + "

1. UMR may require to restart the " + "console in which it will be called " + "(otherwise only newly imported modules will be " + "reloaded when executing files)." + "

2. If errors occur when re-running a " + "PyQt-based program, please check that the Qt objects " + "are properly destroyed (e.g. you may have to use the " + "attribute Qt.WA_DeleteOnClose on your main " + "window, using the setAttribute method)" + ), + ) + umr_verbose_box = newcb( + _("Show reloaded modules list"), + 'umr/verbose', + msg_info=_("Please note that these changes will " + "be applied only to new consoles"), + ) + umr_namelist_btn = QPushButton( + _("Set UMR excluded (not reloaded) modules")) + umr_namelist_btn.clicked.connect(self.set_umr_namelist) + + umr_layout = QVBoxLayout() + umr_layout.addWidget(umr_label) + umr_layout.addWidget(umr_enabled_box) + umr_layout.addWidget(umr_verbose_box) + umr_layout.addWidget(umr_namelist_btn) + umr_group.setLayout(umr_layout) + + vlayout = QVBoxLayout() + vlayout.addWidget(pyexec_group) + vlayout.addWidget(umr_group) + vlayout.addStretch(1) + self.setLayout(vlayout) + + def warn_python_compatibility(self, pyexec): + if not osp.isfile(pyexec): + return + + spyder_version = sys.version_info[0] + try: + args = ["-c", "import sys; print(sys.version_info[0])"] + proc = programs.run_program(pyexec, args, env={}) + console_version = int(proc.communicate()[0]) + except IOError: + console_version = spyder_version + except ValueError: + return False + + if spyder_version != console_version: + QMessageBox.warning( + self, + _('Warning'), + _("You selected a Python %d interpreter for the console " + "but Spyder is running on Python %d!.

" + "Although this is possible, we recommend you to install and " + "run Spyder directly with your selected interpreter, to avoid " + "seeing false warnings and errors due to the incompatible " + "syntax between these two Python versions." + ) % (console_version, spyder_version), + QMessageBox.Ok, + ) + + return True + + def set_umr_namelist(self): + """Set UMR excluded modules name list""" + arguments, valid = QInputDialog.getText( + self, + _('UMR'), + _("Set the list of excluded modules as this: " + "numpy, scipy"), + QLineEdit.Normal, + ", ".join(self.get_option('umr/namelist')), + ) + if valid: + arguments = to_text_string(arguments) + if arguments: + namelist = arguments.replace(' ', '').split(',') + fixed_namelist = [] + non_ascii_namelist = [] + for module_name in namelist: + if PY2: + if all(ord(c) < 128 for c in module_name): + if programs.is_module_installed(module_name): + fixed_namelist.append(module_name) + else: + QMessageBox.warning( + self, + _('Warning'), + _("You are working with Python 2, this means " + "that you can not import a module that " + "contains non-ascii characters."), + QMessageBox.Ok, + ) + non_ascii_namelist.append(module_name) + elif programs.is_module_installed(module_name): + fixed_namelist.append(module_name) + + invalid = ", ".join(set(namelist)-set(fixed_namelist)- + set(non_ascii_namelist)) + if invalid: + QMessageBox.warning( + self, + _('UMR'), + _("The following modules are not " + "installed on your machine:\n%s") % invalid, + QMessageBox.Ok, + ) + QMessageBox.information( + self, + _('UMR'), + _("Please note that these changes will " + "be applied only to new IPython consoles"), + QMessageBox.Ok, + ) + else: + fixed_namelist = [] + + self.set_option('umr/namelist', fixed_namelist) + + def validate_custom_interpreters_list(self): + """Check that the used custom interpreters are still valid.""" + custom_list = self.get_option('custom_interpreters_list') + valid_custom_list = [] + for value in custom_list: + if osp.isfile(value): + valid_custom_list.append(value) + + self.set_option('custom_interpreters_list', valid_custom_list) + + def perform_adjustments(self): + """Perform some adjustments to the page after applying preferences.""" + if not self.def_exec_radio.isChecked(): + # Get current executable + executable = self.pyexec_edit.text() + executable = osp.normpath(executable) + if executable.endswith('pythonw.exe'): + executable = executable.replace("pythonw.exe", "python.exe") + + # Update combobox items. + custom_list = self.cus_exec_combo.combobox.choices + if executable not in custom_list: + custom_list = custom_list + [executable] + self.cus_exec_combo.combobox.clear() + self.cus_exec_combo.combobox.addItems(custom_list) + self.pyexec_edit.setText(executable) + + # Show warning compatibility message. + self.warn_python_compatibility(executable) diff --git a/spyder/plugins/maininterpreter/plugin.py b/spyder/plugins/maininterpreter/plugin.py index 06e89f6ca31..713cb9c9b92 100644 --- a/spyder/plugins/maininterpreter/plugin.py +++ b/spyder/plugins/maininterpreter/plugin.py @@ -1,122 +1,122 @@ -# -*- coding: utf-8 -*- -# -# Copyright (c) 2009- Spyder Project Contributors -# -# Distributed under the terms of the MIT License -# (see spyder/__init__.py for details) - -""" -Main interpreter Plugin. -""" - -# Standard library imports -import os.path as osp - -# Third-party imports -from qtpy.QtCore import Slot - -# Local imports -from spyder.api.plugins import Plugins, SpyderPluginV2 -from spyder.api.plugin_registration.decorators import ( - on_plugin_available, on_plugin_teardown) -from spyder.api.translations import get_translation -from spyder.plugins.maininterpreter.confpage import MainInterpreterConfigPage -from spyder.plugins.maininterpreter.container import MainInterpreterContainer -from spyder.utils.misc import get_python_executable - -# Localization -_ = get_translation('spyder') - - -class MainInterpreter(SpyderPluginV2): - """ - Main interpreter Plugin. - """ - - NAME = "main_interpreter" - REQUIRES = [Plugins.Preferences] - OPTIONAL = [Plugins.StatusBar] - CONTAINER_CLASS = MainInterpreterContainer - CONF_WIDGET_CLASS = MainInterpreterConfigPage - CONF_SECTION = NAME - CONF_FILE = False - CAN_BE_DISABLED = False - - # ---- SpyderPluginV2 API - @staticmethod - def get_name(): - return _("Python interpreter") - - def get_description(self): - return _("Main Python interpreter to open consoles.") - - def get_icon(self): - return self.create_icon('python') - - def on_initialize(self): - container = self.get_container() - - # Connect signal to open preferences - container.sig_open_preferences_requested.connect( - self._open_interpreter_preferences - ) - - # Add custom interpreter to list of saved ones - container.sig_add_to_custom_interpreters_requested.connect( - self._add_to_custom_interpreters - ) - - # Validate that the custom interpreter from the previous session - # still exists - if self.get_conf('custom'): - interpreter = self.get_conf('custom_interpreter') - if not osp.isfile(interpreter): - self.set_conf('custom', False) - self.set_conf('default', True) - self.set_conf('executable', get_python_executable()) - - @on_plugin_available(plugin=Plugins.Preferences) - def on_preferences_available(self): - # Register conf page - preferences = self.get_plugin(Plugins.Preferences) - preferences.register_plugin_preferences(self) - - @on_plugin_available(plugin=Plugins.StatusBar) - def on_statusbar_available(self): - # Add status widget - statusbar = self.get_plugin(Plugins.StatusBar) - statusbar.add_status_widget(self.interpreter_status) - - @on_plugin_teardown(plugin=Plugins.Preferences) - def on_preferences_teardown(self): - # Deregister conf page - preferences = self.get_plugin(Plugins.Preferences) - preferences.deregister_plugin_preferences(self) - - @on_plugin_teardown(plugin=Plugins.StatusBar) - def on_statusbar_teardown(self): - # Add status widget - statusbar = self.get_plugin(Plugins.StatusBar) - statusbar.remove_status_widget(self.interpreter_status.ID) - - @property - def interpreter_status(self): - return self.get_container().interpreter_status - - # ---- Private API - def _open_interpreter_preferences(self): - """Open the Preferences dialog in the main interpreter section.""" - self._main.show_preferences() - preferences = self._main.preferences - container = preferences.get_container() - dlg = container.dialog - index = dlg.get_index_by_name("main_interpreter") - dlg.set_current_index(index) - - @Slot(str) - def _add_to_custom_interpreters(self, interpreter): - """Add a new interpreter to the list of saved ones.""" - custom_list = self.get_conf('custom_interpreters_list') - if interpreter not in custom_list: - custom_list.append(interpreter) - self.set_conf('custom_interpreters_list', custom_list) +# -*- coding: utf-8 -*- +# +# Copyright (c) 2009- Spyder Project Contributors +# +# Distributed under the terms of the MIT License +# (see spyder/__init__.py for details) + +""" +Main interpreter Plugin. +""" + +# Standard library imports +import os.path as osp + +# Third-party imports +from qtpy.QtCore import Slot + +# Local imports +from spyder.api.plugins import Plugins, SpyderPluginV2 +from spyder.api.plugin_registration.decorators import ( + on_plugin_available, on_plugin_teardown) +from spyder.api.translations import get_translation +from spyder.plugins.maininterpreter.confpage import MainInterpreterConfigPage +from spyder.plugins.maininterpreter.container import MainInterpreterContainer +from spyder.utils.misc import get_python_executable + +# Localization +_ = get_translation('spyder') + + +class MainInterpreter(SpyderPluginV2): + """ + Main interpreter Plugin. + """ + + NAME = "main_interpreter" + REQUIRES = [Plugins.Preferences] + OPTIONAL = [Plugins.StatusBar] + CONTAINER_CLASS = MainInterpreterContainer + CONF_WIDGET_CLASS = MainInterpreterConfigPage + CONF_SECTION = NAME + CONF_FILE = False + CAN_BE_DISABLED = False + + # ---- SpyderPluginV2 API + @staticmethod + def get_name(): + return _("Python interpreter") + + def get_description(self): + return _("Main Python interpreter to open consoles.") + + def get_icon(self): + return self.create_icon('python') + + def on_initialize(self): + container = self.get_container() + + # Connect signal to open preferences + container.sig_open_preferences_requested.connect( + self._open_interpreter_preferences + ) + + # Add custom interpreter to list of saved ones + container.sig_add_to_custom_interpreters_requested.connect( + self._add_to_custom_interpreters + ) + + # Validate that the custom interpreter from the previous session + # still exists + if self.get_conf('custom'): + interpreter = self.get_conf('custom_interpreter') + if not osp.isfile(interpreter): + self.set_conf('custom', False) + self.set_conf('default', True) + self.set_conf('executable', get_python_executable()) + + @on_plugin_available(plugin=Plugins.Preferences) + def on_preferences_available(self): + # Register conf page + preferences = self.get_plugin(Plugins.Preferences) + preferences.register_plugin_preferences(self) + + @on_plugin_available(plugin=Plugins.StatusBar) + def on_statusbar_available(self): + # Add status widget + statusbar = self.get_plugin(Plugins.StatusBar) + statusbar.add_status_widget(self.interpreter_status) + + @on_plugin_teardown(plugin=Plugins.Preferences) + def on_preferences_teardown(self): + # Deregister conf page + preferences = self.get_plugin(Plugins.Preferences) + preferences.deregister_plugin_preferences(self) + + @on_plugin_teardown(plugin=Plugins.StatusBar) + def on_statusbar_teardown(self): + # Add status widget + statusbar = self.get_plugin(Plugins.StatusBar) + statusbar.remove_status_widget(self.interpreter_status.ID) + + @property + def interpreter_status(self): + return self.get_container().interpreter_status + + # ---- Private API + def _open_interpreter_preferences(self): + """Open the Preferences dialog in the main interpreter section.""" + self._main.show_preferences() + preferences = self._main.preferences + container = preferences.get_container() + dlg = container.dialog + index = dlg.get_index_by_name("main_interpreter") + dlg.set_current_index(index) + + @Slot(str) + def _add_to_custom_interpreters(self, interpreter): + """Add a new interpreter to the list of saved ones.""" + custom_list = self.get_conf('custom_interpreters_list') + if interpreter not in custom_list: + custom_list.append(interpreter) + self.set_conf('custom_interpreters_list', custom_list) diff --git a/spyder/plugins/onlinehelp/api.py b/spyder/plugins/onlinehelp/api.py index 444ce4138aa..111236d17b1 100644 --- a/spyder/plugins/onlinehelp/api.py +++ b/spyder/plugins/onlinehelp/api.py @@ -1,9 +1,9 @@ -# -*- coding: utf-8 -*- -# -# Copyright © Spyder Project Contributors -# Licensed under the terms of the MIT License -# (see spyder/__init__.py for details) - -# Local imports -from spyder.plugins.onlinehelp.widgets import PydocBrowserActions -from spyder.widgets.browser import WebViewActions +# -*- coding: utf-8 -*- +# +# Copyright © Spyder Project Contributors +# Licensed under the terms of the MIT License +# (see spyder/__init__.py for details) + +# Local imports +from spyder.plugins.onlinehelp.widgets import PydocBrowserActions +from spyder.widgets.browser import WebViewActions diff --git a/spyder/plugins/onlinehelp/plugin.py b/spyder/plugins/onlinehelp/plugin.py index 664c7fab4fd..4bed08d6ab2 100644 --- a/spyder/plugins/onlinehelp/plugin.py +++ b/spyder/plugins/onlinehelp/plugin.py @@ -1,95 +1,95 @@ -# -*- coding: utf-8 -*- -# -# Copyright © Spyder Project Contributors -# Licensed under the terms of the MIT License -# (see spyder/__init__.py for details) - -"""Online Help Plugin""" - -# Standard library imports -import os.path as osp - -# Third party imports -from qtpy.QtCore import Signal - -# Local imports -from spyder.api.plugins import Plugins, SpyderDockablePlugin -from spyder.api.translations import get_translation -from spyder.config.base import get_conf_path -from spyder.plugins.onlinehelp.widgets import PydocBrowser - -# Localization -_ = get_translation('spyder') - - -# --- Plugin -# ---------------------------------------------------------------------------- -class OnlineHelp(SpyderDockablePlugin): - """ - Online Help Plugin. - """ - - NAME = 'onlinehelp' - TABIFY = Plugins.Help - CONF_SECTION = NAME - CONF_FILE = False - WIDGET_CLASS = PydocBrowser - LOG_PATH = get_conf_path(NAME) - - # --- Signals - # ------------------------------------------------------------------------ - sig_load_finished = Signal() - """ - This signal is emitted to indicate the help page has finished loading. - """ - - # --- SpyderDockablePlugin API - # ------------------------------------------------------------------------ - @staticmethod - def get_name(): - return _('Online help') - - def get_description(self): - return _( - 'Browse and search the currently installed modules interactively.') - - def get_icon(self): - return self.create_icon('help') - - def on_close(self, cancelable=False): - self.save_history() - self.set_conf('zoom_factor', - self.get_widget().get_zoom_factor()) - return True - - def on_initialize(self): - widget = self.get_widget() - widget.load_history(self.load_history()) - widget.sig_load_finished.connect(self.sig_load_finished) - - def update_font(self): - self.get_widget().reload() - - # --- Public API - # ------------------------------------------------------------------------ - def load_history(self): - """ - Load history from a text file in the Spyder configuration directory. - """ - if osp.isfile(self.LOG_PATH): - with open(self.LOG_PATH, 'r') as fh: - lines = fh.read().split('\n') - - history = [line.replace('\n', '') for line in lines] - else: - history = [] - - return history - - def save_history(self): - """ - Save history to a text file in the Spyder configuration directory. - """ - data = "\n".join(self.get_widget().get_history()) - with open(self.LOG_PATH, 'w') as fh: - fh.write(data) +# -*- coding: utf-8 -*- +# +# Copyright © Spyder Project Contributors +# Licensed under the terms of the MIT License +# (see spyder/__init__.py for details) + +"""Online Help Plugin""" + +# Standard library imports +import os.path as osp + +# Third party imports +from qtpy.QtCore import Signal + +# Local imports +from spyder.api.plugins import Plugins, SpyderDockablePlugin +from spyder.api.translations import get_translation +from spyder.config.base import get_conf_path +from spyder.plugins.onlinehelp.widgets import PydocBrowser + +# Localization +_ = get_translation('spyder') + + +# --- Plugin +# ---------------------------------------------------------------------------- +class OnlineHelp(SpyderDockablePlugin): + """ + Online Help Plugin. + """ + + NAME = 'onlinehelp' + TABIFY = Plugins.Help + CONF_SECTION = NAME + CONF_FILE = False + WIDGET_CLASS = PydocBrowser + LOG_PATH = get_conf_path(NAME) + + # --- Signals + # ------------------------------------------------------------------------ + sig_load_finished = Signal() + """ + This signal is emitted to indicate the help page has finished loading. + """ + + # --- SpyderDockablePlugin API + # ------------------------------------------------------------------------ + @staticmethod + def get_name(): + return _('Online help') + + def get_description(self): + return _( + 'Browse and search the currently installed modules interactively.') + + def get_icon(self): + return self.create_icon('help') + + def on_close(self, cancelable=False): + self.save_history() + self.set_conf('zoom_factor', + self.get_widget().get_zoom_factor()) + return True + + def on_initialize(self): + widget = self.get_widget() + widget.load_history(self.load_history()) + widget.sig_load_finished.connect(self.sig_load_finished) + + def update_font(self): + self.get_widget().reload() + + # --- Public API + # ------------------------------------------------------------------------ + def load_history(self): + """ + Load history from a text file in the Spyder configuration directory. + """ + if osp.isfile(self.LOG_PATH): + with open(self.LOG_PATH, 'r') as fh: + lines = fh.read().split('\n') + + history = [line.replace('\n', '') for line in lines] + else: + history = [] + + return history + + def save_history(self): + """ + Save history to a text file in the Spyder configuration directory. + """ + data = "\n".join(self.get_widget().get_history()) + with open(self.LOG_PATH, 'w') as fh: + fh.write(data) diff --git a/spyder/plugins/onlinehelp/widgets.py b/spyder/plugins/onlinehelp/widgets.py index 9ebd3ea4d75..d97a9bb6241 100644 --- a/spyder/plugins/onlinehelp/widgets.py +++ b/spyder/plugins/onlinehelp/widgets.py @@ -1,513 +1,513 @@ -# -*- coding: utf-8 -*- -# -# Copyright © Spyder Project Contributors -# Licensed under the terms of the MIT License -# (see spyder/__init__.py for details) - -""" -PyDoc widget. -""" - -# Standard library imports -import os.path as osp -import pydoc -import sys - -# Third party imports -from qtpy.QtCore import Qt, QThread, QUrl, Signal, Slot -from qtpy.QtGui import QCursor -from qtpy.QtWebEngineWidgets import WEBENGINE -from qtpy.QtWidgets import QApplication, QLabel, QVBoxLayout - -# Local imports -from spyder.api.translations import get_translation -from spyder.api.widgets.main_widget import PluginMainWidget -from spyder.plugins.onlinehelp.pydoc_patch import _start_server, _url_handler -from spyder.widgets.browser import FrameWebView, WebViewActions -from spyder.widgets.comboboxes import UrlComboBox -from spyder.widgets.findreplace import FindReplace - - -# Localization -_ = get_translation('spyder') - - -# --- Constants -# ---------------------------------------------------------------------------- -PORT = 30128 - - -class PydocBrowserActions: - # Triggers - Home = 'home_action' - Find = 'find_action' - - -class PydocBrowserMainToolbarSections: - Main = 'main_section' - - -class PydocBrowserToolbarItems: - PackageLabel = 'package_label' - UrlCombo = 'url_combo' - - -# ============================================================================= -# Pydoc adjustments -# ============================================================================= -# This is needed to prevent pydoc raise an ErrorDuringImport when -# trying to import numpy. -# See spyder-ide/spyder#10740 -DIRECT_PYDOC_IMPORT_MODULES = ['numpy', 'numpy.core'] -try: - from pydoc import safeimport - - def spyder_safeimport(path, forceload=0, cache={}): - if path in DIRECT_PYDOC_IMPORT_MODULES: - forceload = 0 - return safeimport(path, forceload=forceload, cache=cache) - - pydoc.safeimport = spyder_safeimport -except Exception: - pass - - -class PydocServer(QThread): - """ - Pydoc server. - """ - sig_server_started = Signal() - - def __init__(self, parent, port): - QThread.__init__(self, parent) - - self.port = port - self.server = None - self.complete = False - self.closed = False - - def run(self): - self.callback( - _start_server( - _url_handler, - hostname='127.0.0.1', - port=self.port, - ) - ) - - def callback(self, server): - self.server = server - if self.closed: - self.quit_server() - else: - self.sig_server_started.emit() - - def completer(self): - self.complete = True - - def is_running(self): - """Check if the server is running""" - if self.isRunning(): - # Startup - return True - - if self.server is None: - return False - - return self.server.serving - - def quit_server(self): - self.closed = True - if self.server is None: - return - - if self.server.serving: - self.server.stop() - - -class PydocBrowser(PluginMainWidget): - """PyDoc browser widget.""" - - ENABLE_SPINNER = True - - # --- Signals - # ------------------------------------------------------------------------ - sig_load_finished = Signal() - """ - This signal is emitted to indicate the help page has finished loading. - """ - - def __init__(self, name=None, plugin=None, parent=None): - super().__init__(name, plugin, parent=parent) - - self._is_running = False - self.home_url = None - self.server = None - - # Widgets - self.label = QLabel(_("Package:")) - self.label.ID = PydocBrowserToolbarItems.PackageLabel - - self.url_combo = UrlComboBox( - self, id_=PydocBrowserToolbarItems.UrlCombo) - - # Setup web view frame - self.webview = FrameWebView( - self, - handle_links=self.get_conf('handle_links') - ) - self.webview.setup() - self.webview.set_zoom_factor(self.get_conf('zoom_factor')) - self.webview.loadStarted.connect(self._start) - self.webview.loadFinished.connect(self._finish) - self.webview.titleChanged.connect(self.setWindowTitle) - self.webview.urlChanged.connect(self._change_url) - if not WEBENGINE: - self.webview.iconChanged.connect(self._handle_icon_change) - - # Setup find widget - self.find_widget = FindReplace(self) - self.find_widget.set_editor(self.webview) - self.find_widget.hide() - self.url_combo.setMaxCount(self.get_conf('max_history_entries')) - tip = _('Write a package name here, e.g. pandas') - self.url_combo.lineEdit().setPlaceholderText(tip) - self.url_combo.lineEdit().setToolTip(tip) - self.url_combo.valid.connect( - lambda x: self._handle_url_combo_activation()) - - # Layout - layout = QVBoxLayout() - layout.addWidget(self.webview) - layout.addSpacing(1) - layout.addWidget(self.find_widget) - self.setLayout(layout) - - # --- PluginMainWidget API - # ------------------------------------------------------------------------ - def get_title(self): - return _('Online help') - - def get_focus_widget(self): - self.url_combo.lineEdit().selectAll() - return self.url_combo - - def setup(self): - # Actions - home_action = self.create_action( - PydocBrowserActions.Home, - text=_("Home"), - tip=_("Home"), - icon=self.create_icon('home'), - triggered=self.go_home, - ) - find_action = self.create_action( - PydocBrowserActions.Find, - text=_("Find"), - tip=_("Find text"), - icon=self.create_icon('find'), - toggled=self.toggle_find_widget, - initial=False, - ) - stop_action = self.get_action(WebViewActions.Stop) - refresh_action = self.get_action(WebViewActions.Refresh) - - # Toolbar - toolbar = self.get_main_toolbar() - for item in [self.get_action(WebViewActions.Back), - self.get_action(WebViewActions.Forward), refresh_action, - stop_action, home_action, self.label, self.url_combo, - self.get_action(WebViewActions.ZoomIn), - self.get_action(WebViewActions.ZoomOut), find_action, - ]: - self.add_item_to_toolbar( - item, - toolbar=toolbar, - section=PydocBrowserMainToolbarSections.Main, - ) - - # Signals - self.find_widget.visibility_changed.connect(find_action.setChecked) - - for __, action in self.get_actions().items(): - if action: - # IMPORTANT: Since we are defining the main actions in here - # and the context is WidgetWithChildrenShortcut we need to - # assign the same actions to the children widgets in order - # for shortcuts to work - try: - self.webview.addAction(action) - except RuntimeError: - pass - - self.sig_toggle_view_changed.connect(self.initialize) - - def update_actions(self): - stop_action = self.get_action(WebViewActions.Stop) - refresh_action = self.get_action(WebViewActions.Refresh) - - refresh_action.setVisible(not self._is_running) - stop_action.setVisible(self._is_running) - - # --- Private API - # ------------------------------------------------------------------------ - def _start(self): - """Webview load started.""" - self._is_running = True - self.start_spinner() - self.update_actions() - - def _finish(self, code): - """Webview load finished.""" - self._is_running = False - self.stop_spinner() - self.update_actions() - self.sig_load_finished.emit() - - def _continue_initialization(self): - """Load home page.""" - self.go_home() - QApplication.restoreOverrideCursor() - - def _handle_url_combo_activation(self): - """Load URL from combo box first item.""" - if not self._is_running: - text = str(self.url_combo.currentText()) - self.go_to(self.text_to_url(text)) - else: - self.get_action(WebViewActions.Stop).trigger() - - self.get_focus_widget().setFocus() - - def _change_url(self, url): - """ - Displayed URL has changed -> updating URL combo box. - """ - self.url_combo.add_text(self.url_to_text(url)) - - def _handle_icon_change(self): - """ - Handle icon changes. - """ - self.url_combo.setItemIcon(self.url_combo.currentIndex(), - self.webview.icon()) - self.setWindowIcon(self.webview.icon()) - - # --- Qt overrides - # ------------------------------------------------------------------------ - def closeEvent(self, event): - self.webview.web_widget.stop() - if self.server: - self.server.finished.connect(self.deleteLater) - self.quit_server() - super().closeEvent(event) - - # --- Public API - # ------------------------------------------------------------------------ - def load_history(self, history): - """ - Load history. - - Parameters - ---------- - history: list - List of searched items. - """ - self.url_combo.addItems(history) - - @Slot(bool) - def initialize(self, checked=True, force=False): - """ - Start pydoc server. - - Parameters - ---------- - checked: bool, optional - This method is connected to the `sig_toggle_view_changed` signal, - so that the first time the widget is made visible it will start - the server. Default is True. - force: bool, optional - Force a server start even if the server is running. - Default is False. - """ - server_needed = checked and self.server is None - if force or server_needed or not self.is_server_running(): - self.sig_toggle_view_changed.disconnect(self.initialize) - QApplication.setOverrideCursor(QCursor(Qt.WaitCursor)) - self.start_server() - - def is_server_running(self): - """Return True if pydoc server is already running.""" - return self.server is not None and self.server.is_running() - - def start_server(self): - """Start pydoc server.""" - if self.server is None: - self.set_home_url('http://127.0.0.1:{}/'.format(PORT)) - elif self.server.is_running(): - self.server.sig_server_started.disconnect( - self._continue_initialization) - self.server.quit() - self.server.wait() - - self.server = PydocServer(None, port=PORT) - self.server.sig_server_started.connect( - self._continue_initialization) - self.server.start() - - def quit_server(self): - """Quit the server.""" - if self.server is None: - return - - if self.server.is_running(): - self.server.sig_server_started.disconnect( - self._continue_initialization) - self.server.quit_server() - self.server.quit() - self.server.wait() - - def get_label(self): - """Return address label text""" - return _("Package:") - - def reload(self): - """Reload page.""" - if self.server: - self.webview.reload() - - def text_to_url(self, text): - """ - Convert text address into QUrl object. - - Parameters - ---------- - text: str - Url address. - """ - if text != 'about:blank': - text += '.html' - - if text.startswith('/'): - text = text[1:] - - return QUrl(self.home_url.toString() + text) - - def url_to_text(self, url): - """ - Convert QUrl object to displayed text in combo box. - - Parameters - ---------- - url: QUrl - Url address. - """ - string_url = url.toString() - if 'about:blank' in string_url: - return 'about:blank' - elif 'get?key=' in string_url or 'search?key=' in string_url: - return url.toString().split('=')[-1] - - return osp.splitext(str(url.path()))[0][1:] - - def set_home_url(self, text): - """ - Set home URL. - - Parameters - ---------- - text: str - Home url address. - """ - self.home_url = QUrl(text) - - def set_url(self, url): - """ - Set current URL. - - Parameters - ---------- - url: QUrl or str - Url address. - """ - self._change_url(url) - self.go_to(url) - - def go_to(self, url_or_text): - """ - Go to page URL. - """ - if isinstance(url_or_text, str): - url = QUrl(url_or_text) - else: - url = url_or_text - - self.webview.load(url) - - @Slot() - def go_home(self): - """ - Go to home page. - """ - if self.home_url is not None: - self.set_url(self.home_url) - - def get_zoom_factor(self): - """ - Get the current zoom factor. - - Returns - ------- - int - Zoom factor. - """ - return self.webview.get_zoom_factor() - - def get_history(self): - """ - Return the list of history items in the combobox. - - Returns - ------- - list - List of strings. - """ - history = [] - for index in range(self.url_combo.count()): - history.append(str(self.url_combo.itemText(index))) - - return history - - @Slot(bool) - def toggle_find_widget(self, state): - """ - Show/hide the find widget. - - Parameters - ---------- - state: bool - True to show and False to hide the find widget. - """ - if state: - self.find_widget.show() - else: - self.find_widget.hide() - - -def test(): - """Run web browser.""" - from spyder.utils.qthelpers import qapplication - from unittest.mock import MagicMock - - plugin_mock = MagicMock() - plugin_mock.CONF_SECTION = 'onlinehelp' - app = qapplication(test_time=8) - widget = PydocBrowser(None, plugin=plugin_mock) - widget._setup() - widget.setup() - widget.show() - sys.exit(app.exec_()) - - -if __name__ == '__main__': - test() +# -*- coding: utf-8 -*- +# +# Copyright © Spyder Project Contributors +# Licensed under the terms of the MIT License +# (see spyder/__init__.py for details) + +""" +PyDoc widget. +""" + +# Standard library imports +import os.path as osp +import pydoc +import sys + +# Third party imports +from qtpy.QtCore import Qt, QThread, QUrl, Signal, Slot +from qtpy.QtGui import QCursor +from qtpy.QtWebEngineWidgets import WEBENGINE +from qtpy.QtWidgets import QApplication, QLabel, QVBoxLayout + +# Local imports +from spyder.api.translations import get_translation +from spyder.api.widgets.main_widget import PluginMainWidget +from spyder.plugins.onlinehelp.pydoc_patch import _start_server, _url_handler +from spyder.widgets.browser import FrameWebView, WebViewActions +from spyder.widgets.comboboxes import UrlComboBox +from spyder.widgets.findreplace import FindReplace + + +# Localization +_ = get_translation('spyder') + + +# --- Constants +# ---------------------------------------------------------------------------- +PORT = 30128 + + +class PydocBrowserActions: + # Triggers + Home = 'home_action' + Find = 'find_action' + + +class PydocBrowserMainToolbarSections: + Main = 'main_section' + + +class PydocBrowserToolbarItems: + PackageLabel = 'package_label' + UrlCombo = 'url_combo' + + +# ============================================================================= +# Pydoc adjustments +# ============================================================================= +# This is needed to prevent pydoc raise an ErrorDuringImport when +# trying to import numpy. +# See spyder-ide/spyder#10740 +DIRECT_PYDOC_IMPORT_MODULES = ['numpy', 'numpy.core'] +try: + from pydoc import safeimport + + def spyder_safeimport(path, forceload=0, cache={}): + if path in DIRECT_PYDOC_IMPORT_MODULES: + forceload = 0 + return safeimport(path, forceload=forceload, cache=cache) + + pydoc.safeimport = spyder_safeimport +except Exception: + pass + + +class PydocServer(QThread): + """ + Pydoc server. + """ + sig_server_started = Signal() + + def __init__(self, parent, port): + QThread.__init__(self, parent) + + self.port = port + self.server = None + self.complete = False + self.closed = False + + def run(self): + self.callback( + _start_server( + _url_handler, + hostname='127.0.0.1', + port=self.port, + ) + ) + + def callback(self, server): + self.server = server + if self.closed: + self.quit_server() + else: + self.sig_server_started.emit() + + def completer(self): + self.complete = True + + def is_running(self): + """Check if the server is running""" + if self.isRunning(): + # Startup + return True + + if self.server is None: + return False + + return self.server.serving + + def quit_server(self): + self.closed = True + if self.server is None: + return + + if self.server.serving: + self.server.stop() + + +class PydocBrowser(PluginMainWidget): + """PyDoc browser widget.""" + + ENABLE_SPINNER = True + + # --- Signals + # ------------------------------------------------------------------------ + sig_load_finished = Signal() + """ + This signal is emitted to indicate the help page has finished loading. + """ + + def __init__(self, name=None, plugin=None, parent=None): + super().__init__(name, plugin, parent=parent) + + self._is_running = False + self.home_url = None + self.server = None + + # Widgets + self.label = QLabel(_("Package:")) + self.label.ID = PydocBrowserToolbarItems.PackageLabel + + self.url_combo = UrlComboBox( + self, id_=PydocBrowserToolbarItems.UrlCombo) + + # Setup web view frame + self.webview = FrameWebView( + self, + handle_links=self.get_conf('handle_links') + ) + self.webview.setup() + self.webview.set_zoom_factor(self.get_conf('zoom_factor')) + self.webview.loadStarted.connect(self._start) + self.webview.loadFinished.connect(self._finish) + self.webview.titleChanged.connect(self.setWindowTitle) + self.webview.urlChanged.connect(self._change_url) + if not WEBENGINE: + self.webview.iconChanged.connect(self._handle_icon_change) + + # Setup find widget + self.find_widget = FindReplace(self) + self.find_widget.set_editor(self.webview) + self.find_widget.hide() + self.url_combo.setMaxCount(self.get_conf('max_history_entries')) + tip = _('Write a package name here, e.g. pandas') + self.url_combo.lineEdit().setPlaceholderText(tip) + self.url_combo.lineEdit().setToolTip(tip) + self.url_combo.valid.connect( + lambda x: self._handle_url_combo_activation()) + + # Layout + layout = QVBoxLayout() + layout.addWidget(self.webview) + layout.addSpacing(1) + layout.addWidget(self.find_widget) + self.setLayout(layout) + + # --- PluginMainWidget API + # ------------------------------------------------------------------------ + def get_title(self): + return _('Online help') + + def get_focus_widget(self): + self.url_combo.lineEdit().selectAll() + return self.url_combo + + def setup(self): + # Actions + home_action = self.create_action( + PydocBrowserActions.Home, + text=_("Home"), + tip=_("Home"), + icon=self.create_icon('home'), + triggered=self.go_home, + ) + find_action = self.create_action( + PydocBrowserActions.Find, + text=_("Find"), + tip=_("Find text"), + icon=self.create_icon('find'), + toggled=self.toggle_find_widget, + initial=False, + ) + stop_action = self.get_action(WebViewActions.Stop) + refresh_action = self.get_action(WebViewActions.Refresh) + + # Toolbar + toolbar = self.get_main_toolbar() + for item in [self.get_action(WebViewActions.Back), + self.get_action(WebViewActions.Forward), refresh_action, + stop_action, home_action, self.label, self.url_combo, + self.get_action(WebViewActions.ZoomIn), + self.get_action(WebViewActions.ZoomOut), find_action, + ]: + self.add_item_to_toolbar( + item, + toolbar=toolbar, + section=PydocBrowserMainToolbarSections.Main, + ) + + # Signals + self.find_widget.visibility_changed.connect(find_action.setChecked) + + for __, action in self.get_actions().items(): + if action: + # IMPORTANT: Since we are defining the main actions in here + # and the context is WidgetWithChildrenShortcut we need to + # assign the same actions to the children widgets in order + # for shortcuts to work + try: + self.webview.addAction(action) + except RuntimeError: + pass + + self.sig_toggle_view_changed.connect(self.initialize) + + def update_actions(self): + stop_action = self.get_action(WebViewActions.Stop) + refresh_action = self.get_action(WebViewActions.Refresh) + + refresh_action.setVisible(not self._is_running) + stop_action.setVisible(self._is_running) + + # --- Private API + # ------------------------------------------------------------------------ + def _start(self): + """Webview load started.""" + self._is_running = True + self.start_spinner() + self.update_actions() + + def _finish(self, code): + """Webview load finished.""" + self._is_running = False + self.stop_spinner() + self.update_actions() + self.sig_load_finished.emit() + + def _continue_initialization(self): + """Load home page.""" + self.go_home() + QApplication.restoreOverrideCursor() + + def _handle_url_combo_activation(self): + """Load URL from combo box first item.""" + if not self._is_running: + text = str(self.url_combo.currentText()) + self.go_to(self.text_to_url(text)) + else: + self.get_action(WebViewActions.Stop).trigger() + + self.get_focus_widget().setFocus() + + def _change_url(self, url): + """ + Displayed URL has changed -> updating URL combo box. + """ + self.url_combo.add_text(self.url_to_text(url)) + + def _handle_icon_change(self): + """ + Handle icon changes. + """ + self.url_combo.setItemIcon(self.url_combo.currentIndex(), + self.webview.icon()) + self.setWindowIcon(self.webview.icon()) + + # --- Qt overrides + # ------------------------------------------------------------------------ + def closeEvent(self, event): + self.webview.web_widget.stop() + if self.server: + self.server.finished.connect(self.deleteLater) + self.quit_server() + super().closeEvent(event) + + # --- Public API + # ------------------------------------------------------------------------ + def load_history(self, history): + """ + Load history. + + Parameters + ---------- + history: list + List of searched items. + """ + self.url_combo.addItems(history) + + @Slot(bool) + def initialize(self, checked=True, force=False): + """ + Start pydoc server. + + Parameters + ---------- + checked: bool, optional + This method is connected to the `sig_toggle_view_changed` signal, + so that the first time the widget is made visible it will start + the server. Default is True. + force: bool, optional + Force a server start even if the server is running. + Default is False. + """ + server_needed = checked and self.server is None + if force or server_needed or not self.is_server_running(): + self.sig_toggle_view_changed.disconnect(self.initialize) + QApplication.setOverrideCursor(QCursor(Qt.WaitCursor)) + self.start_server() + + def is_server_running(self): + """Return True if pydoc server is already running.""" + return self.server is not None and self.server.is_running() + + def start_server(self): + """Start pydoc server.""" + if self.server is None: + self.set_home_url('http://127.0.0.1:{}/'.format(PORT)) + elif self.server.is_running(): + self.server.sig_server_started.disconnect( + self._continue_initialization) + self.server.quit() + self.server.wait() + + self.server = PydocServer(None, port=PORT) + self.server.sig_server_started.connect( + self._continue_initialization) + self.server.start() + + def quit_server(self): + """Quit the server.""" + if self.server is None: + return + + if self.server.is_running(): + self.server.sig_server_started.disconnect( + self._continue_initialization) + self.server.quit_server() + self.server.quit() + self.server.wait() + + def get_label(self): + """Return address label text""" + return _("Package:") + + def reload(self): + """Reload page.""" + if self.server: + self.webview.reload() + + def text_to_url(self, text): + """ + Convert text address into QUrl object. + + Parameters + ---------- + text: str + Url address. + """ + if text != 'about:blank': + text += '.html' + + if text.startswith('/'): + text = text[1:] + + return QUrl(self.home_url.toString() + text) + + def url_to_text(self, url): + """ + Convert QUrl object to displayed text in combo box. + + Parameters + ---------- + url: QUrl + Url address. + """ + string_url = url.toString() + if 'about:blank' in string_url: + return 'about:blank' + elif 'get?key=' in string_url or 'search?key=' in string_url: + return url.toString().split('=')[-1] + + return osp.splitext(str(url.path()))[0][1:] + + def set_home_url(self, text): + """ + Set home URL. + + Parameters + ---------- + text: str + Home url address. + """ + self.home_url = QUrl(text) + + def set_url(self, url): + """ + Set current URL. + + Parameters + ---------- + url: QUrl or str + Url address. + """ + self._change_url(url) + self.go_to(url) + + def go_to(self, url_or_text): + """ + Go to page URL. + """ + if isinstance(url_or_text, str): + url = QUrl(url_or_text) + else: + url = url_or_text + + self.webview.load(url) + + @Slot() + def go_home(self): + """ + Go to home page. + """ + if self.home_url is not None: + self.set_url(self.home_url) + + def get_zoom_factor(self): + """ + Get the current zoom factor. + + Returns + ------- + int + Zoom factor. + """ + return self.webview.get_zoom_factor() + + def get_history(self): + """ + Return the list of history items in the combobox. + + Returns + ------- + list + List of strings. + """ + history = [] + for index in range(self.url_combo.count()): + history.append(str(self.url_combo.itemText(index))) + + return history + + @Slot(bool) + def toggle_find_widget(self, state): + """ + Show/hide the find widget. + + Parameters + ---------- + state: bool + True to show and False to hide the find widget. + """ + if state: + self.find_widget.show() + else: + self.find_widget.hide() + + +def test(): + """Run web browser.""" + from spyder.utils.qthelpers import qapplication + from unittest.mock import MagicMock + + plugin_mock = MagicMock() + plugin_mock.CONF_SECTION = 'onlinehelp' + app = qapplication(test_time=8) + widget = PydocBrowser(None, plugin=plugin_mock) + widget._setup() + widget.setup() + widget.show() + sys.exit(app.exec_()) + + +if __name__ == '__main__': + test() diff --git a/spyder/plugins/outlineexplorer/plugin.py b/spyder/plugins/outlineexplorer/plugin.py index 3839f01e0a5..378bf5daab4 100644 --- a/spyder/plugins/outlineexplorer/plugin.py +++ b/spyder/plugins/outlineexplorer/plugin.py @@ -1,108 +1,108 @@ -# -*- coding: utf-8 -*- -# -# Copyright © Spyder Project Contributors -# Licensed under the terms of the MIT License -# (see spyder/__init__.py for details) - -"""Outline Explorer Plugin.""" - -# Third party imports -from qtpy.QtCore import Slot - -# Local imports -from spyder.api.plugin_registration.decorators import ( - on_plugin_available, on_plugin_teardown) -from spyder.api.translations import get_translation -from spyder.api.plugins import SpyderDockablePlugin, Plugins -from spyder.plugins.outlineexplorer.main_widget import OutlineExplorerWidget - -# Localization -_ = get_translation('spyder') - - -class OutlineExplorer(SpyderDockablePlugin): - NAME = 'outline_explorer' - CONF_SECTION = 'outline_explorer' - REQUIRES = [Plugins.Completions, Plugins.Editor] - OPTIONAL = [] - - CONF_FILE = False - WIDGET_CLASS = OutlineExplorerWidget - - # ---- SpyderDockablePlugin API - # ------------------------------------------------------------------------ - @staticmethod - def get_name() -> str: - """Return widget title.""" - return _('Outline Explorer') - - def get_description(self) -> str: - """Return the description of the outline explorer widget.""" - return _("Explore a file's functions, classes and methods") - - def get_icon(self): - """Return the outline explorer icon.""" - return self.create_icon('outline_explorer') - - def on_initialize(self): - if self.main: - self.main.restore_scrollbar_position.connect( - self.restore_scrollbar_position) - - @on_plugin_available(plugin=Plugins.Completions) - def on_completions_available(self): - completions = self.get_plugin(Plugins.Completions) - - completions.sig_language_completions_available.connect( - self.start_symbol_services) - completions.sig_stop_completions.connect( - self.stop_symbol_services) - - @on_plugin_available(plugin=Plugins.Editor) - def on_editor_available(self): - editor = self.get_plugin(Plugins.Editor) - - editor.sig_open_files_finished.connect( - self.update_all_editors) - - @on_plugin_teardown(plugin=Plugins.Completions) - def on_completions_teardown(self): - completions = self.get_plugin(Plugins.Completions) - - completions.sig_language_completions_available.disconnect( - self.start_symbol_services) - completions.sig_stop_completions.disconnect( - self.stop_symbol_services) - - @on_plugin_teardown(plugin=Plugins.Editor) - def on_editor_teardown(self): - editor = self.get_plugin(Plugins.Editor) - - editor.sig_open_files_finished.disconnect( - self.update_all_editors) - - #------ Public API --------------------------------------------------------- - def restore_scrollbar_position(self): - """Restoring scrollbar position after main window is visible""" - scrollbar_pos = self.get_conf('scrollbar_position', None) - explorer = self.get_widget() - if scrollbar_pos is not None: - explorer.treewidget.set_scrollbar_position(scrollbar_pos) - - @Slot(dict, str) - def start_symbol_services(self, capabilities, language): - """Enable LSP symbols functionality.""" - explorer = self.get_widget() - symbol_provider = capabilities.get('documentSymbolProvider', False) - if symbol_provider: - explorer.start_symbol_services(language) - - def stop_symbol_services(self, language): - """Disable LSP symbols functionality.""" - explorer = self.get_widget() - explorer.stop_symbol_services(language) - - def update_all_editors(self): - """Update all editors with an associated LSP server.""" - explorer = self.get_widget() - explorer.update_all_editors() +# -*- coding: utf-8 -*- +# +# Copyright © Spyder Project Contributors +# Licensed under the terms of the MIT License +# (see spyder/__init__.py for details) + +"""Outline Explorer Plugin.""" + +# Third party imports +from qtpy.QtCore import Slot + +# Local imports +from spyder.api.plugin_registration.decorators import ( + on_plugin_available, on_plugin_teardown) +from spyder.api.translations import get_translation +from spyder.api.plugins import SpyderDockablePlugin, Plugins +from spyder.plugins.outlineexplorer.main_widget import OutlineExplorerWidget + +# Localization +_ = get_translation('spyder') + + +class OutlineExplorer(SpyderDockablePlugin): + NAME = 'outline_explorer' + CONF_SECTION = 'outline_explorer' + REQUIRES = [Plugins.Completions, Plugins.Editor] + OPTIONAL = [] + + CONF_FILE = False + WIDGET_CLASS = OutlineExplorerWidget + + # ---- SpyderDockablePlugin API + # ------------------------------------------------------------------------ + @staticmethod + def get_name() -> str: + """Return widget title.""" + return _('Outline Explorer') + + def get_description(self) -> str: + """Return the description of the outline explorer widget.""" + return _("Explore a file's functions, classes and methods") + + def get_icon(self): + """Return the outline explorer icon.""" + return self.create_icon('outline_explorer') + + def on_initialize(self): + if self.main: + self.main.restore_scrollbar_position.connect( + self.restore_scrollbar_position) + + @on_plugin_available(plugin=Plugins.Completions) + def on_completions_available(self): + completions = self.get_plugin(Plugins.Completions) + + completions.sig_language_completions_available.connect( + self.start_symbol_services) + completions.sig_stop_completions.connect( + self.stop_symbol_services) + + @on_plugin_available(plugin=Plugins.Editor) + def on_editor_available(self): + editor = self.get_plugin(Plugins.Editor) + + editor.sig_open_files_finished.connect( + self.update_all_editors) + + @on_plugin_teardown(plugin=Plugins.Completions) + def on_completions_teardown(self): + completions = self.get_plugin(Plugins.Completions) + + completions.sig_language_completions_available.disconnect( + self.start_symbol_services) + completions.sig_stop_completions.disconnect( + self.stop_symbol_services) + + @on_plugin_teardown(plugin=Plugins.Editor) + def on_editor_teardown(self): + editor = self.get_plugin(Plugins.Editor) + + editor.sig_open_files_finished.disconnect( + self.update_all_editors) + + #------ Public API --------------------------------------------------------- + def restore_scrollbar_position(self): + """Restoring scrollbar position after main window is visible""" + scrollbar_pos = self.get_conf('scrollbar_position', None) + explorer = self.get_widget() + if scrollbar_pos is not None: + explorer.treewidget.set_scrollbar_position(scrollbar_pos) + + @Slot(dict, str) + def start_symbol_services(self, capabilities, language): + """Enable LSP symbols functionality.""" + explorer = self.get_widget() + symbol_provider = capabilities.get('documentSymbolProvider', False) + if symbol_provider: + explorer.start_symbol_services(language) + + def stop_symbol_services(self, language): + """Disable LSP symbols functionality.""" + explorer = self.get_widget() + explorer.stop_symbol_services(language) + + def update_all_editors(self): + """Update all editors with an associated LSP server.""" + explorer = self.get_widget() + explorer.update_all_editors() diff --git a/spyder/plugins/outlineexplorer/widgets.py b/spyder/plugins/outlineexplorer/widgets.py index e9ff550afdd..3d5ddf4391a 100644 --- a/spyder/plugins/outlineexplorer/widgets.py +++ b/spyder/plugins/outlineexplorer/widgets.py @@ -1,887 +1,887 @@ -# -*- coding: utf-8 -*- -# -# Copyright © Spyder Project Contributors -# Licensed under the terms of the MIT License -# (see spyder/__init__.py for details) - -"""Outline explorer widgets.""" - -# Standard library imports -import bisect -import os.path as osp -import uuid - -# Third party imports -from intervaltree import IntervalTree -from pkg_resources import parse_version -from qtpy import PYSIDE2 -from qtpy.compat import from_qvariant -from qtpy.QtCore import Qt, QTimer, Signal, Slot -from qtpy.QtWidgets import QTreeWidgetItem, QTreeWidgetItemIterator - -# Local imports -from spyder.api.config.decorators import on_conf_change -from spyder.config.base import _ -from spyder.py3compat import to_text_string -from spyder.utils.icon_manager import ima -from spyder.plugins.completion.api import SymbolKind, SYMBOL_KIND_ICON -from spyder.utils.qthelpers import set_item_user_text -from spyder.widgets.onecolumntree import OneColumnTree - -if PYSIDE2: - from qtpy import PYSIDE_VERSION - - -# ---- Constants -# ----------------------------------------------------------------------------- -SYMBOL_NAME_MAP = { - SymbolKind.FILE: _('File'), - SymbolKind.MODULE: _('Module'), - SymbolKind.NAMESPACE: _('Namespace'), - SymbolKind.PACKAGE: _('Package'), - SymbolKind.CLASS: _('Class'), - SymbolKind.METHOD: _('Method'), - SymbolKind.PROPERTY: _('Property'), - SymbolKind.FIELD: _('Attribute'), - SymbolKind.CONSTRUCTOR: _('constructor'), - SymbolKind.ENUM: _('Enum'), - SymbolKind.INTERFACE: _('Interface'), - SymbolKind.FUNCTION: _('Function'), - SymbolKind.VARIABLE: _('Variable'), - SymbolKind.CONSTANT: _('Constant'), - SymbolKind.STRING: _('String'), - SymbolKind.NUMBER: _('Number'), - SymbolKind.BOOLEAN: _('Boolean'), - SymbolKind.ARRAY: _('Array'), - SymbolKind.OBJECT: _('Object'), - SymbolKind.KEY: _('Key'), - SymbolKind.NULL: _('Null'), - SymbolKind.ENUM_MEMBER: _('Enum member'), - SymbolKind.STRUCT: _('Struct'), - SymbolKind.EVENT: _('Event'), - SymbolKind.OPERATOR: _('Operator'), - SymbolKind.TYPE_PARAMETER: _('Type parameter'), - SymbolKind.CELL: _('Cell'), - SymbolKind.BLOCK_COMMENT: _('Block comment') -} - - -# ---- Symbol status -# ----------------------------------------------------------------------------- -class SymbolStatus: - def __init__(self, name, kind, position, path, node=None): - self.name = name - self.position = position - self.kind = kind - self.node = node - self.path = path - self.id = str(uuid.uuid4()) - self.index = None - self.children = [] - self.status = False - self.selected = False - self.parent = None - - def delete(self): - for child in self.children: - child.parent = None - - self.children = [] - self.node.takeChildren() - - if self.parent is not None: - self.parent.remove_node(self) - self.parent = None - - if self.node.parent is not None: - self.node.parent.remove_children(self.node) - - def add_node(self, node): - if node.position == self.position: - # The nodes should be at the same level - self.parent.add_node(node) - else: - node.parent = self - node.path = self.path - this_node = self.node - children_ranges = [c.position[0] for c in self.children] - node_range = node.position[0] - new_index = bisect.bisect_left(children_ranges, node_range) - node.index = new_index - for child in self.children[new_index:]: - child.index += 1 - this_node.append_children(new_index, node.node) - self.children.insert(new_index, node) - for idx, next_idx in zip(self.children, self.children[1:]): - assert idx.index < next_idx.index - - def remove_node(self, node): - for child in self.children[node.index + 1:]: - child.index -= 1 - self.children.pop(node.index) - for idx, next_idx in zip(self.children, self.children[1:]): - assert idx.index < next_idx.index - - def clone_node(self, node): - self.id = node.id - self.index = node.index - self.path = node.path - self.children = node.children - self.status = node.status - self.selected = node.selected - self.node = node.node - self.parent = node.parent - self.node.update_info(self.name, self.kind, self.position[0] + 1, - self.status, self.selected) - self.node.ref = self - - for child in self.children: - child.parent = self - - if self.parent is not None: - self.parent.replace_node(self.index, self) - - def refresh(self): - self.node.update_info(self.name, self.kind, self.position[0] + 1, - self.status, self.selected) - - def replace_node(self, index, node): - self.children[index] = node - - def create_node(self): - self.node = SymbolItem(None, self, self.name, self.kind, - self.position[0] + 1, self.status, - self.selected) - - def __repr__(self): - return str(self) - - def __str__(self): - return '({0}, {1}, {2}, {3})'.format( - self.position, self.name, self.id, self.status) - - -# ---- Items -# ----------------------------------------------------------------------------- -class BaseTreeItem(QTreeWidgetItem): - def clear(self): - self.takeChildren() - - def append_children(self, index, node): - self.insertChild(index, node) - node.parent = self - - def remove_children(self, node): - self.removeChild(node) - node.parent = None - - -class FileRootItem(BaseTreeItem): - def __init__(self, path, ref, treewidget, is_python=True): - QTreeWidgetItem.__init__(self, treewidget, QTreeWidgetItem.Type) - self.path = path - self.ref = ref - self.setIcon( - 0, ima.icon('python') if is_python else ima.icon('TextFileIcon')) - self.setToolTip(0, path) - set_item_user_text(self, path) - - def set_path(self, path, fullpath): - self.path = path - self.set_text(fullpath) - - def set_text(self, fullpath): - self.setText(0, self.path if fullpath else osp.basename(self.path)) - - -class SymbolItem(BaseTreeItem): - """Generic symbol tree item.""" - def __init__(self, parent, ref, name, kind, position, status, selected): - QTreeWidgetItem.__init__(self, parent, QTreeWidgetItem.Type) - self.parent = parent - self.ref = ref - self.num_children = 0 - self.update_info(name, kind, position, status, selected) - - def update_info(self, name, kind, position, status, selected): - self.setIcon(0, ima.icon(SYMBOL_KIND_ICON.get(kind, 'no_match'))) - identifier = SYMBOL_NAME_MAP.get(kind, '') - identifier = identifier.replace('_', ' ').capitalize() - self.setToolTip(0, '{3} {2}: {0} {1}'.format( - identifier, name, position, _('Line'))) - set_item_user_text(self, name) - self.setText(0, name) - self.setExpanded(status) - self.setSelected(selected) - - -class TreeItem(QTreeWidgetItem): - """Class browser item base class.""" - def __init__(self, oedata, parent, preceding): - if preceding is None: - QTreeWidgetItem.__init__(self, parent, QTreeWidgetItem.Type) - else: - if preceding is not parent: - # Preceding must be either the same as item's parent - # or have the same parent as item - while preceding.parent() is not parent: - preceding = preceding.parent() - if preceding is None: - break - if preceding is None: - QTreeWidgetItem.__init__(self, parent, QTreeWidgetItem.Type) - else: - QTreeWidgetItem.__init__(self, parent, preceding, - QTreeWidgetItem.Type) - self.parent_item = parent - self.oedata = oedata - oedata.sig_update.connect(self.update) - self.update() - - def level(self): - """Get fold level.""" - return self.oedata.fold_level - - def get_name(self): - """Get the item name.""" - return self.oedata.def_name - - def set_icon(self, icon): - self.setIcon(0, icon) - - def setup(self): - self.setToolTip(0, _("Line %s") % str(self.line)) - - @property - def line(self): - """Get line number.""" - block_number = self.oedata.get_block_number() - if block_number is not None: - return block_number + 1 - return None - - def update(self): - """Update the tree element.""" - name = self.get_name() - self.setText(0, name) - parent_text = from_qvariant(self.parent_item.data(0, Qt.UserRole), - to_text_string) - set_item_user_text(self, parent_text + '/' + name) - self.setup() - - -# ---- Treewidget -# ----------------------------------------------------------------------------- -class OutlineExplorerTreeWidget(OneColumnTree): - # Used only for debug purposes - sig_tree_updated = Signal() - sig_display_spinner = Signal() - sig_hide_spinner = Signal() - sig_update_configuration = Signal() - - CONF_SECTION = 'outline_explorer' - - def __init__(self, parent): - if hasattr(parent, 'CONTEXT_NAME'): - self.CONTEXT_NAME = parent.CONTEXT_NAME - - self.show_fullpath = self.get_conf('show_fullpath') - self.show_all_files = self.get_conf('show_all_files') - self.group_cells = self.get_conf('group_cells') - self.show_comments = self.get_conf('show_comments') - self.sort_files_alphabetically = self.get_conf( - 'sort_files_alphabetically') - self.follow_cursor = self.get_conf('follow_cursor') - self.display_variables = self.get_conf('display_variables') - - super().__init__(parent) - - self.freeze = False # Freezing widget to avoid any unwanted update - self.editor_items = {} - self.editor_tree_cache = {} - self.editor_ids = {} - self.update_timers = {} - self.editors_to_update = {} - self.ordered_editor_ids = [] - self._current_editor = None - self._languages = [] - self.is_visible = True - - self.currentItemChanged.connect(self.selection_switched) - self.itemExpanded.connect(self.tree_item_expanded) - self.itemCollapsed.connect(self.tree_item_collapsed) - - # ---- SpyderWidgetMixin API - # ------------------------------------------------------------------------ - @property - def current_editor(self): - """Get current editor.""" - return self._current_editor - - @current_editor.setter - def current_editor(self, value): - """Set current editor and connect the necessary signals.""" - if self._current_editor == value: - return - # Disconnect previous editor - self.connect_current_editor(False) - self._current_editor = value - # Connect new editor - self.connect_current_editor(True) - - def __hide_or_show_root_items(self, item): - """ - show_all_files option is disabled: hide all root items except *item* - show_all_files option is enabled: do nothing - """ - for _it in self.get_top_level_items(): - _it.setHidden(_it is not item and not self.show_all_files) - - @on_conf_change(option='show_fullpath') - def toggle_fullpath_mode(self, state): - self.show_fullpath = state - self.setTextElideMode(Qt.ElideMiddle if state else Qt.ElideRight) - for index in range(self.topLevelItemCount()): - self.topLevelItem(index).set_text(fullpath=self.show_fullpath) - - @on_conf_change(option='show_all_files') - def toggle_show_all_files(self, state): - self.show_all_files = state - if self.current_editor is not None: - editor_id = self.editor_ids[self.current_editor] - item = self.editor_items[editor_id].node - self.__hide_or_show_root_items(item) - self.__sort_toplevel_items() - if self.show_all_files is False: - self.root_item_selected( - self.editor_items[self.editor_ids[self.current_editor]]) - self.do_follow_cursor() - - @on_conf_change(option='show_comments') - def toggle_show_comments(self, state): - self.show_comments = state - self.sig_update_configuration.emit() - self.update_editors(language='python') - - @on_conf_change(option='group_cells') - def toggle_group_cells(self, state): - self.group_cells = state - self.sig_update_configuration.emit() - self.update_editors(language='python') - - @on_conf_change(option='display_variables') - def toggle_variables(self, state): - self.display_variables = state - for editor in self.editor_ids.keys(): - self.update_editor(editor.info, editor) - - @on_conf_change(option='sort_files_alphabetically') - def toggle_sort_files_alphabetically(self, state): - self.sort_files_alphabetically = state - self.__sort_toplevel_items() - - @on_conf_change(option='follow_cursor') - def toggle_follow_cursor(self, state): - """Follow the cursor.""" - self.follow_cursor = state - self.do_follow_cursor() - - @Slot() - def do_follow_cursor(self): - """Go to cursor position.""" - if self.follow_cursor: - self.go_to_cursor_position() - - @Slot() - def go_to_cursor_position(self): - if self.current_editor is not None: - editor_id = self.editor_ids[self.current_editor] - line = self.current_editor.get_cursor_line_number() - tree = self.editor_tree_cache[editor_id] - root = self.editor_items[editor_id] - overlap = tree[line - 1] - if len(overlap) == 0: - item = root.node - self.setCurrentItem(item) - self.scrollToItem(item) - self.expandItem(item) - else: - sorted_nodes = sorted(overlap) - # The last item of the sorted elements correspond to the - # current node if expanding, otherwise it is the first stopper - # found - idx = -1 - self.switch_to_node(sorted_nodes, idx) - - def switch_to_node(self, sorted_nodes, idx): - """Given a set of tree nodes, highlight the node on index `idx`.""" - item_interval = sorted_nodes[idx] - item_ref = item_interval.data - item = item_ref.node - self.setCurrentItem(item) - self.scrollToItem(item) - self.expandItem(item) - - def connect_current_editor(self, state): - """Connect or disconnect the editor from signals.""" - editor = self.current_editor - if editor is None: - return - - # Connect syntax highlighter - sig_update = editor.sig_outline_explorer_data_changed - sig_move = editor.sig_cursor_position_changed - sig_display_spinner = editor.sig_start_outline_spinner - if state: - sig_update.connect(self.update_editor) - sig_move.connect(self.do_follow_cursor) - sig_display_spinner.connect(self.sig_display_spinner) - self.do_follow_cursor() - else: - try: - sig_update.disconnect(self.update_editor) - sig_move.disconnect(self.do_follow_cursor) - sig_display_spinner.disconnect(self.sig_display_spinner) - except TypeError: - # This catches an error while performing - # teardown in one of our tests. - pass - - def clear(self): - """Reimplemented Qt method""" - self.set_title('') - OneColumnTree.clear(self) - - def set_current_editor(self, editor, update): - """Bind editor instance""" - editor_id = editor.get_id() - - # Don't fail if editor doesn't exist anymore. This - # happens when switching projects. - try: - item = self.editor_items[editor_id].node - except KeyError: - return - - if not self.freeze: - self.scrollToItem(item) - self.root_item_selected(item) - self.__hide_or_show_root_items(item) - if update: - self.save_expanded_state() - self.restore_expanded_state() - - self.current_editor = editor - - # Update tree with currently stored info or require symbols if - # necessary. - if (editor.get_language().lower() in self._languages and - len(self.editor_tree_cache[editor_id]) == 0): - if editor.info is not None: - self.update_editor(editor.info) - elif editor.is_cloned: - editor.request_symbols() - - def register_editor(self, editor): - """ - Register editor attributes and create basic objects associated - to it. - """ - editor_id = editor.get_id() - self.editor_ids[editor] = editor_id - self.ordered_editor_ids.append(editor_id) - - this_root = SymbolStatus(editor.fname, None, None, editor.fname) - self.editor_items[editor_id] = this_root - - root_item = FileRootItem(editor.fname, this_root, - self, editor.is_python()) - this_root.node = root_item - root_item.set_text(fullpath=self.show_fullpath) - self.resizeColumnToContents(0) - if not self.show_all_files: - root_item.setHidden(True) - - editor_tree = IntervalTree() - self.editor_tree_cache[editor_id] = editor_tree - - self.__sort_toplevel_items() - - def file_renamed(self, editor, new_filename): - """File was renamed, updating outline explorer tree""" - if editor is None: - # This is needed when we can't find an editor to attach - # the outline explorer to. - # Fix spyder-ide/spyder#8813. - return - editor_id = editor.get_id() - if editor_id in list(self.editor_ids.values()): - root_item = self.editor_items[editor_id].node - root_item.set_path(new_filename, fullpath=self.show_fullpath) - self.__sort_toplevel_items() - - def update_editors(self, language): - """ - Update all editors for a given language sequentially. - - This is done through a timer to avoid lags in the interface. - """ - if self.editors_to_update.get(language): - editor = self.editors_to_update[language][0] - if editor.info is not None: - # Editor could be not there anymore after switching - # projects - try: - self.update_editor(editor.info, editor) - except KeyError: - pass - self.editors_to_update[language].remove(editor) - self.update_timers[language].start() - - def update_all_editors(self, reset_info=False): - """Update all editors with LSP support.""" - for language in self._languages: - self.set_editors_to_update(language, reset_info=reset_info) - self.update_timers[language].start() - - @Slot(list) - def update_editor(self, items, editor=None): - """ - Update the outline explorer for `editor` preserving the tree - state. - """ - if items is None: - return - - # Only perform an update if the widget is visible. - if not self.is_visible: - self.sig_hide_spinner.emit() - return - - if editor is None: - editor = self.current_editor - editor_id = editor.get_id() - language = editor.get_language() - - update = self.update_tree(items, editor_id, language) - - if update: - self.save_expanded_state() - self.restore_expanded_state() - self.do_follow_cursor() - - def merge_interval(self, parent, node): - """Add node into an existing tree structure.""" - match = False - start, end = node.position - while parent.parent is not None and not match: - parent_start, parent_end = parent.position - if parent_end <= start: - parent = parent.parent - else: - match = True - - if node.parent is not None: - node.parent.remove_node(node) - node.parent = None - if node.node.parent is not None: - node.node.parent.remove_children(node.node) - - parent.add_node(node) - node.refresh() - return node - - def update_tree(self, items, editor_id, language): - """Update tree with new items that come from the LSP.""" - current_tree = self.editor_tree_cache[editor_id] - tree_info = [] - for symbol in items: - symbol_name = symbol['name'] - symbol_kind = symbol['kind'] - if language.lower() == 'python': - if symbol_kind == SymbolKind.MODULE: - continue - if (symbol_kind == SymbolKind.VARIABLE and - not self.display_variables): - continue - if (symbol_kind == SymbolKind.FIELD and - not self.display_variables): - continue - # NOTE: This could be also a DocumentSymbol - symbol_range = symbol['location']['range'] - symbol_start = symbol_range['start']['line'] - symbol_end = symbol_range['end']['line'] - symbol_repr = SymbolStatus(symbol_name, symbol_kind, - (symbol_start, symbol_end), None) - tree_info.append((symbol_start, symbol_end + 1, symbol_repr)) - - tree = IntervalTree.from_tuples(tree_info) - changes = tree - current_tree - deleted = current_tree - tree - - if len(changes) == 0 and len(deleted) == 0: - self.sig_hide_spinner.emit() - return False - - adding_symbols = len(changes) > len(deleted) - deleted_iter = iter(sorted(deleted)) - changes_iter = iter(sorted(changes)) - - deleted_entry = next(deleted_iter, None) - changed_entry = next(changes_iter, None) - non_merged = 0 - - while deleted_entry is not None and changed_entry is not None: - deleted_entry_i = deleted_entry.data - changed_entry_i = changed_entry.data - - if deleted_entry_i.name == changed_entry_i.name: - # Copy symbol status - changed_entry_i.clone_node(deleted_entry_i) - deleted_entry = next(deleted_iter, None) - changed_entry = next(changes_iter, None) - else: - if adding_symbols: - # New symbol added - changed_entry_i.create_node() - non_merged += 1 - changed_entry = next(changes_iter, None) - else: - # Symbol removed - deleted_entry_i.delete() - non_merged += 1 - deleted_entry = next(deleted_iter, None) - - if deleted_entry is not None: - while deleted_entry is not None: - # Symbol removed - deleted_entry_i = deleted_entry.data - deleted_entry_i.delete() - non_merged += 1 - deleted_entry = next(deleted_iter, None) - - root = self.editor_items[editor_id] - # tree_merge - if changed_entry is not None: - while changed_entry is not None: - # New symbol added - changed_entry_i = changed_entry.data - changed_entry_i.create_node() - non_merged += 1 - changed_entry = next(changes_iter, None) - - tree_copy = IntervalTree(tree) - tree_copy.merge_overlaps( - data_reducer=self.merge_interval, data_initializer=root) - - self.editor_tree_cache[editor_id] = tree - self.sig_tree_updated.emit() - self.sig_hide_spinner.emit() - return True - - def remove_editor(self, editor): - if editor in self.editor_ids: - if self.current_editor is editor: - self.current_editor = None - editor_id = self.editor_ids.pop(editor) - if editor_id in self.ordered_editor_ids: - self.ordered_editor_ids.remove(editor_id) - if editor_id not in list(self.editor_ids.values()): - root_item = self.editor_items.pop(editor_id) - self.editor_tree_cache.pop(editor_id) - try: - self.takeTopLevelItem( - self.indexOfTopLevelItem(root_item.node)) - except RuntimeError: - # item has already been removed - pass - - def set_editor_ids_order(self, ordered_editor_ids): - """ - Order the root file items in the Outline Explorer following the - provided list of editor ids. - """ - if self.ordered_editor_ids != ordered_editor_ids: - self.ordered_editor_ids = ordered_editor_ids - if self.sort_files_alphabetically is False: - self.__sort_toplevel_items() - - def __sort_toplevel_items(self): - """ - Sort the root file items in alphabetical order if - 'sort_files_alphabetically' is True, else order the items as - specified in the 'self.ordered_editor_ids' list. - """ - if self.show_all_files is False: - return - - current_ordered_items = [self.topLevelItem(index) for index in - range(self.topLevelItemCount())] - - # Convert list to a dictionary in order to remove duplicated entries - # when having multiple editors (splitted or in new windows). - # See spyder-ide/spyder#14646 - current_ordered_items_dict = { - item.path.lower(): item for item in current_ordered_items} - - if self.sort_files_alphabetically: - new_ordered_items = sorted( - current_ordered_items_dict.values(), - key=lambda item: osp.basename(item.path.lower())) - else: - new_ordered_items = [ - self.editor_items.get(e_id).node for e_id in - self.ordered_editor_ids if - self.editor_items.get(e_id) is not None] - - # PySide <= 5.15.0 doesn’t support == and != comparison for the data - # types inside the compared lists (see [1], [2]) - # - # [1] https://bugreports.qt.io/browse/PYSIDE-74 - # [2] https://codereview.qt-project.org/c/pyside/pyside-setup/+/312945 - update = ( - (PYSIDE2 and parse_version(PYSIDE_VERSION) <= parse_version("5.15.0")) - or (current_ordered_items != new_ordered_items) - ) - if update: - selected_items = self.selectedItems() - self.save_expanded_state() - for index in range(self.topLevelItemCount()): - self.takeTopLevelItem(0) - for index, item in enumerate(new_ordered_items): - self.insertTopLevelItem(index, item) - self.restore_expanded_state() - self.clearSelection() - if selected_items: - selected_items[-1].setSelected(True) - - def root_item_selected(self, item): - """Root item has been selected: expanding it and collapsing others""" - if self.show_all_files: - return - for root_item in self.get_top_level_items(): - if root_item is item: - self.expandItem(root_item) - else: - self.collapseItem(root_item) - - def restore(self): - """Reimplemented OneColumnTree method""" - if self.current_editor is not None: - self.collapseAll() - editor_id = self.editor_ids[self.current_editor] - self.root_item_selected(self.editor_items[editor_id].node) - - def get_root_item(self, item): - """Return the root item of the specified item.""" - root_item = item - while isinstance(root_item.parent(), QTreeWidgetItem): - root_item = root_item.parent() - return root_item - - def get_visible_items(self): - """Return a list of all visible items in the treewidget.""" - items = [] - iterator = QTreeWidgetItemIterator(self) - while iterator.value(): - item = iterator.value() - if not item.isHidden(): - if item.parent(): - if item.parent().isExpanded(): - items.append(item) - else: - items.append(item) - iterator += 1 - return items - - def activated(self, item): - """Double-click event""" - editor_root = self.editor_items.get( - self.editor_ids.get(self.current_editor)) - root_item = editor_root.node - text = '' - if isinstance(item, FileRootItem): - line = None - if id(root_item) != id(item): - root_item = item - else: - line = item.ref.position[0] + 1 - text = item.ref.name - - path = item.ref.path - self.freeze = True - if line: - self.parent().edit_goto.emit(path, line, text) - else: - self.parent().edit.emit(path) - self.freeze = False - - for editor_id, i_item in list(self.editor_items.items()): - if i_item.path == path: - for editor, _id in list(self.editor_ids.items()): - self.current_editor = editor - break - break - - def clicked(self, item): - """Click event""" - if isinstance(item, FileRootItem): - self.root_item_selected(item) - self.activated(item) - - def selection_switched(self, current_item, previous_item): - if current_item is not None: - current_ref = current_item.ref - current_ref.selected = True - if previous_item is not None: - previous_ref = previous_item.ref - previous_ref.selected = False - - def tree_item_collapsed(self, item): - ref = item.ref - ref.status = False - - def tree_item_expanded(self, item): - ref = item.ref - ref.status = True - - def set_editors_to_update(self, language, reset_info=False): - """Set editors to update per language.""" - to_update = [] - for editor in self.editor_ids.keys(): - if editor.get_language().lower() == language: - to_update.append(editor) - if reset_info: - editor.info = None - self.editors_to_update[language] = to_update - - def start_symbol_services(self, language): - """Show symbols for all `language` files.""" - # Save all languages that can send info to this pane. - self._languages.append(language) - - # Update all files associated to `language` through a timer - # that allows to wait a bit between updates. That doesn't block - # the interface at startup. - timer = QTimer(self) - timer.setSingleShot(True) - timer.setInterval(700) - timer.timeout.connect(lambda: self.update_editors(language)) - self.update_timers[language] = timer - - # Set editors that need to be updated per language - self.set_editors_to_update(language) - - # Start timer - timer.start() - - def stop_symbol_services(self, language): - """Disable LSP symbols functionality.""" - try: - self._languages.remove(language) - except ValueError: - pass - - for editor in self.editor_ids.keys(): - if editor.get_language().lower() == language: - editor.info = None +# -*- coding: utf-8 -*- +# +# Copyright © Spyder Project Contributors +# Licensed under the terms of the MIT License +# (see spyder/__init__.py for details) + +"""Outline explorer widgets.""" + +# Standard library imports +import bisect +import os.path as osp +import uuid + +# Third party imports +from intervaltree import IntervalTree +from pkg_resources import parse_version +from qtpy import PYSIDE2 +from qtpy.compat import from_qvariant +from qtpy.QtCore import Qt, QTimer, Signal, Slot +from qtpy.QtWidgets import QTreeWidgetItem, QTreeWidgetItemIterator + +# Local imports +from spyder.api.config.decorators import on_conf_change +from spyder.config.base import _ +from spyder.py3compat import to_text_string +from spyder.utils.icon_manager import ima +from spyder.plugins.completion.api import SymbolKind, SYMBOL_KIND_ICON +from spyder.utils.qthelpers import set_item_user_text +from spyder.widgets.onecolumntree import OneColumnTree + +if PYSIDE2: + from qtpy import PYSIDE_VERSION + + +# ---- Constants +# ----------------------------------------------------------------------------- +SYMBOL_NAME_MAP = { + SymbolKind.FILE: _('File'), + SymbolKind.MODULE: _('Module'), + SymbolKind.NAMESPACE: _('Namespace'), + SymbolKind.PACKAGE: _('Package'), + SymbolKind.CLASS: _('Class'), + SymbolKind.METHOD: _('Method'), + SymbolKind.PROPERTY: _('Property'), + SymbolKind.FIELD: _('Attribute'), + SymbolKind.CONSTRUCTOR: _('constructor'), + SymbolKind.ENUM: _('Enum'), + SymbolKind.INTERFACE: _('Interface'), + SymbolKind.FUNCTION: _('Function'), + SymbolKind.VARIABLE: _('Variable'), + SymbolKind.CONSTANT: _('Constant'), + SymbolKind.STRING: _('String'), + SymbolKind.NUMBER: _('Number'), + SymbolKind.BOOLEAN: _('Boolean'), + SymbolKind.ARRAY: _('Array'), + SymbolKind.OBJECT: _('Object'), + SymbolKind.KEY: _('Key'), + SymbolKind.NULL: _('Null'), + SymbolKind.ENUM_MEMBER: _('Enum member'), + SymbolKind.STRUCT: _('Struct'), + SymbolKind.EVENT: _('Event'), + SymbolKind.OPERATOR: _('Operator'), + SymbolKind.TYPE_PARAMETER: _('Type parameter'), + SymbolKind.CELL: _('Cell'), + SymbolKind.BLOCK_COMMENT: _('Block comment') +} + + +# ---- Symbol status +# ----------------------------------------------------------------------------- +class SymbolStatus: + def __init__(self, name, kind, position, path, node=None): + self.name = name + self.position = position + self.kind = kind + self.node = node + self.path = path + self.id = str(uuid.uuid4()) + self.index = None + self.children = [] + self.status = False + self.selected = False + self.parent = None + + def delete(self): + for child in self.children: + child.parent = None + + self.children = [] + self.node.takeChildren() + + if self.parent is not None: + self.parent.remove_node(self) + self.parent = None + + if self.node.parent is not None: + self.node.parent.remove_children(self.node) + + def add_node(self, node): + if node.position == self.position: + # The nodes should be at the same level + self.parent.add_node(node) + else: + node.parent = self + node.path = self.path + this_node = self.node + children_ranges = [c.position[0] for c in self.children] + node_range = node.position[0] + new_index = bisect.bisect_left(children_ranges, node_range) + node.index = new_index + for child in self.children[new_index:]: + child.index += 1 + this_node.append_children(new_index, node.node) + self.children.insert(new_index, node) + for idx, next_idx in zip(self.children, self.children[1:]): + assert idx.index < next_idx.index + + def remove_node(self, node): + for child in self.children[node.index + 1:]: + child.index -= 1 + self.children.pop(node.index) + for idx, next_idx in zip(self.children, self.children[1:]): + assert idx.index < next_idx.index + + def clone_node(self, node): + self.id = node.id + self.index = node.index + self.path = node.path + self.children = node.children + self.status = node.status + self.selected = node.selected + self.node = node.node + self.parent = node.parent + self.node.update_info(self.name, self.kind, self.position[0] + 1, + self.status, self.selected) + self.node.ref = self + + for child in self.children: + child.parent = self + + if self.parent is not None: + self.parent.replace_node(self.index, self) + + def refresh(self): + self.node.update_info(self.name, self.kind, self.position[0] + 1, + self.status, self.selected) + + def replace_node(self, index, node): + self.children[index] = node + + def create_node(self): + self.node = SymbolItem(None, self, self.name, self.kind, + self.position[0] + 1, self.status, + self.selected) + + def __repr__(self): + return str(self) + + def __str__(self): + return '({0}, {1}, {2}, {3})'.format( + self.position, self.name, self.id, self.status) + + +# ---- Items +# ----------------------------------------------------------------------------- +class BaseTreeItem(QTreeWidgetItem): + def clear(self): + self.takeChildren() + + def append_children(self, index, node): + self.insertChild(index, node) + node.parent = self + + def remove_children(self, node): + self.removeChild(node) + node.parent = None + + +class FileRootItem(BaseTreeItem): + def __init__(self, path, ref, treewidget, is_python=True): + QTreeWidgetItem.__init__(self, treewidget, QTreeWidgetItem.Type) + self.path = path + self.ref = ref + self.setIcon( + 0, ima.icon('python') if is_python else ima.icon('TextFileIcon')) + self.setToolTip(0, path) + set_item_user_text(self, path) + + def set_path(self, path, fullpath): + self.path = path + self.set_text(fullpath) + + def set_text(self, fullpath): + self.setText(0, self.path if fullpath else osp.basename(self.path)) + + +class SymbolItem(BaseTreeItem): + """Generic symbol tree item.""" + def __init__(self, parent, ref, name, kind, position, status, selected): + QTreeWidgetItem.__init__(self, parent, QTreeWidgetItem.Type) + self.parent = parent + self.ref = ref + self.num_children = 0 + self.update_info(name, kind, position, status, selected) + + def update_info(self, name, kind, position, status, selected): + self.setIcon(0, ima.icon(SYMBOL_KIND_ICON.get(kind, 'no_match'))) + identifier = SYMBOL_NAME_MAP.get(kind, '') + identifier = identifier.replace('_', ' ').capitalize() + self.setToolTip(0, '{3} {2}: {0} {1}'.format( + identifier, name, position, _('Line'))) + set_item_user_text(self, name) + self.setText(0, name) + self.setExpanded(status) + self.setSelected(selected) + + +class TreeItem(QTreeWidgetItem): + """Class browser item base class.""" + def __init__(self, oedata, parent, preceding): + if preceding is None: + QTreeWidgetItem.__init__(self, parent, QTreeWidgetItem.Type) + else: + if preceding is not parent: + # Preceding must be either the same as item's parent + # or have the same parent as item + while preceding.parent() is not parent: + preceding = preceding.parent() + if preceding is None: + break + if preceding is None: + QTreeWidgetItem.__init__(self, parent, QTreeWidgetItem.Type) + else: + QTreeWidgetItem.__init__(self, parent, preceding, + QTreeWidgetItem.Type) + self.parent_item = parent + self.oedata = oedata + oedata.sig_update.connect(self.update) + self.update() + + def level(self): + """Get fold level.""" + return self.oedata.fold_level + + def get_name(self): + """Get the item name.""" + return self.oedata.def_name + + def set_icon(self, icon): + self.setIcon(0, icon) + + def setup(self): + self.setToolTip(0, _("Line %s") % str(self.line)) + + @property + def line(self): + """Get line number.""" + block_number = self.oedata.get_block_number() + if block_number is not None: + return block_number + 1 + return None + + def update(self): + """Update the tree element.""" + name = self.get_name() + self.setText(0, name) + parent_text = from_qvariant(self.parent_item.data(0, Qt.UserRole), + to_text_string) + set_item_user_text(self, parent_text + '/' + name) + self.setup() + + +# ---- Treewidget +# ----------------------------------------------------------------------------- +class OutlineExplorerTreeWidget(OneColumnTree): + # Used only for debug purposes + sig_tree_updated = Signal() + sig_display_spinner = Signal() + sig_hide_spinner = Signal() + sig_update_configuration = Signal() + + CONF_SECTION = 'outline_explorer' + + def __init__(self, parent): + if hasattr(parent, 'CONTEXT_NAME'): + self.CONTEXT_NAME = parent.CONTEXT_NAME + + self.show_fullpath = self.get_conf('show_fullpath') + self.show_all_files = self.get_conf('show_all_files') + self.group_cells = self.get_conf('group_cells') + self.show_comments = self.get_conf('show_comments') + self.sort_files_alphabetically = self.get_conf( + 'sort_files_alphabetically') + self.follow_cursor = self.get_conf('follow_cursor') + self.display_variables = self.get_conf('display_variables') + + super().__init__(parent) + + self.freeze = False # Freezing widget to avoid any unwanted update + self.editor_items = {} + self.editor_tree_cache = {} + self.editor_ids = {} + self.update_timers = {} + self.editors_to_update = {} + self.ordered_editor_ids = [] + self._current_editor = None + self._languages = [] + self.is_visible = True + + self.currentItemChanged.connect(self.selection_switched) + self.itemExpanded.connect(self.tree_item_expanded) + self.itemCollapsed.connect(self.tree_item_collapsed) + + # ---- SpyderWidgetMixin API + # ------------------------------------------------------------------------ + @property + def current_editor(self): + """Get current editor.""" + return self._current_editor + + @current_editor.setter + def current_editor(self, value): + """Set current editor and connect the necessary signals.""" + if self._current_editor == value: + return + # Disconnect previous editor + self.connect_current_editor(False) + self._current_editor = value + # Connect new editor + self.connect_current_editor(True) + + def __hide_or_show_root_items(self, item): + """ + show_all_files option is disabled: hide all root items except *item* + show_all_files option is enabled: do nothing + """ + for _it in self.get_top_level_items(): + _it.setHidden(_it is not item and not self.show_all_files) + + @on_conf_change(option='show_fullpath') + def toggle_fullpath_mode(self, state): + self.show_fullpath = state + self.setTextElideMode(Qt.ElideMiddle if state else Qt.ElideRight) + for index in range(self.topLevelItemCount()): + self.topLevelItem(index).set_text(fullpath=self.show_fullpath) + + @on_conf_change(option='show_all_files') + def toggle_show_all_files(self, state): + self.show_all_files = state + if self.current_editor is not None: + editor_id = self.editor_ids[self.current_editor] + item = self.editor_items[editor_id].node + self.__hide_or_show_root_items(item) + self.__sort_toplevel_items() + if self.show_all_files is False: + self.root_item_selected( + self.editor_items[self.editor_ids[self.current_editor]]) + self.do_follow_cursor() + + @on_conf_change(option='show_comments') + def toggle_show_comments(self, state): + self.show_comments = state + self.sig_update_configuration.emit() + self.update_editors(language='python') + + @on_conf_change(option='group_cells') + def toggle_group_cells(self, state): + self.group_cells = state + self.sig_update_configuration.emit() + self.update_editors(language='python') + + @on_conf_change(option='display_variables') + def toggle_variables(self, state): + self.display_variables = state + for editor in self.editor_ids.keys(): + self.update_editor(editor.info, editor) + + @on_conf_change(option='sort_files_alphabetically') + def toggle_sort_files_alphabetically(self, state): + self.sort_files_alphabetically = state + self.__sort_toplevel_items() + + @on_conf_change(option='follow_cursor') + def toggle_follow_cursor(self, state): + """Follow the cursor.""" + self.follow_cursor = state + self.do_follow_cursor() + + @Slot() + def do_follow_cursor(self): + """Go to cursor position.""" + if self.follow_cursor: + self.go_to_cursor_position() + + @Slot() + def go_to_cursor_position(self): + if self.current_editor is not None: + editor_id = self.editor_ids[self.current_editor] + line = self.current_editor.get_cursor_line_number() + tree = self.editor_tree_cache[editor_id] + root = self.editor_items[editor_id] + overlap = tree[line - 1] + if len(overlap) == 0: + item = root.node + self.setCurrentItem(item) + self.scrollToItem(item) + self.expandItem(item) + else: + sorted_nodes = sorted(overlap) + # The last item of the sorted elements correspond to the + # current node if expanding, otherwise it is the first stopper + # found + idx = -1 + self.switch_to_node(sorted_nodes, idx) + + def switch_to_node(self, sorted_nodes, idx): + """Given a set of tree nodes, highlight the node on index `idx`.""" + item_interval = sorted_nodes[idx] + item_ref = item_interval.data + item = item_ref.node + self.setCurrentItem(item) + self.scrollToItem(item) + self.expandItem(item) + + def connect_current_editor(self, state): + """Connect or disconnect the editor from signals.""" + editor = self.current_editor + if editor is None: + return + + # Connect syntax highlighter + sig_update = editor.sig_outline_explorer_data_changed + sig_move = editor.sig_cursor_position_changed + sig_display_spinner = editor.sig_start_outline_spinner + if state: + sig_update.connect(self.update_editor) + sig_move.connect(self.do_follow_cursor) + sig_display_spinner.connect(self.sig_display_spinner) + self.do_follow_cursor() + else: + try: + sig_update.disconnect(self.update_editor) + sig_move.disconnect(self.do_follow_cursor) + sig_display_spinner.disconnect(self.sig_display_spinner) + except TypeError: + # This catches an error while performing + # teardown in one of our tests. + pass + + def clear(self): + """Reimplemented Qt method""" + self.set_title('') + OneColumnTree.clear(self) + + def set_current_editor(self, editor, update): + """Bind editor instance""" + editor_id = editor.get_id() + + # Don't fail if editor doesn't exist anymore. This + # happens when switching projects. + try: + item = self.editor_items[editor_id].node + except KeyError: + return + + if not self.freeze: + self.scrollToItem(item) + self.root_item_selected(item) + self.__hide_or_show_root_items(item) + if update: + self.save_expanded_state() + self.restore_expanded_state() + + self.current_editor = editor + + # Update tree with currently stored info or require symbols if + # necessary. + if (editor.get_language().lower() in self._languages and + len(self.editor_tree_cache[editor_id]) == 0): + if editor.info is not None: + self.update_editor(editor.info) + elif editor.is_cloned: + editor.request_symbols() + + def register_editor(self, editor): + """ + Register editor attributes and create basic objects associated + to it. + """ + editor_id = editor.get_id() + self.editor_ids[editor] = editor_id + self.ordered_editor_ids.append(editor_id) + + this_root = SymbolStatus(editor.fname, None, None, editor.fname) + self.editor_items[editor_id] = this_root + + root_item = FileRootItem(editor.fname, this_root, + self, editor.is_python()) + this_root.node = root_item + root_item.set_text(fullpath=self.show_fullpath) + self.resizeColumnToContents(0) + if not self.show_all_files: + root_item.setHidden(True) + + editor_tree = IntervalTree() + self.editor_tree_cache[editor_id] = editor_tree + + self.__sort_toplevel_items() + + def file_renamed(self, editor, new_filename): + """File was renamed, updating outline explorer tree""" + if editor is None: + # This is needed when we can't find an editor to attach + # the outline explorer to. + # Fix spyder-ide/spyder#8813. + return + editor_id = editor.get_id() + if editor_id in list(self.editor_ids.values()): + root_item = self.editor_items[editor_id].node + root_item.set_path(new_filename, fullpath=self.show_fullpath) + self.__sort_toplevel_items() + + def update_editors(self, language): + """ + Update all editors for a given language sequentially. + + This is done through a timer to avoid lags in the interface. + """ + if self.editors_to_update.get(language): + editor = self.editors_to_update[language][0] + if editor.info is not None: + # Editor could be not there anymore after switching + # projects + try: + self.update_editor(editor.info, editor) + except KeyError: + pass + self.editors_to_update[language].remove(editor) + self.update_timers[language].start() + + def update_all_editors(self, reset_info=False): + """Update all editors with LSP support.""" + for language in self._languages: + self.set_editors_to_update(language, reset_info=reset_info) + self.update_timers[language].start() + + @Slot(list) + def update_editor(self, items, editor=None): + """ + Update the outline explorer for `editor` preserving the tree + state. + """ + if items is None: + return + + # Only perform an update if the widget is visible. + if not self.is_visible: + self.sig_hide_spinner.emit() + return + + if editor is None: + editor = self.current_editor + editor_id = editor.get_id() + language = editor.get_language() + + update = self.update_tree(items, editor_id, language) + + if update: + self.save_expanded_state() + self.restore_expanded_state() + self.do_follow_cursor() + + def merge_interval(self, parent, node): + """Add node into an existing tree structure.""" + match = False + start, end = node.position + while parent.parent is not None and not match: + parent_start, parent_end = parent.position + if parent_end <= start: + parent = parent.parent + else: + match = True + + if node.parent is not None: + node.parent.remove_node(node) + node.parent = None + if node.node.parent is not None: + node.node.parent.remove_children(node.node) + + parent.add_node(node) + node.refresh() + return node + + def update_tree(self, items, editor_id, language): + """Update tree with new items that come from the LSP.""" + current_tree = self.editor_tree_cache[editor_id] + tree_info = [] + for symbol in items: + symbol_name = symbol['name'] + symbol_kind = symbol['kind'] + if language.lower() == 'python': + if symbol_kind == SymbolKind.MODULE: + continue + if (symbol_kind == SymbolKind.VARIABLE and + not self.display_variables): + continue + if (symbol_kind == SymbolKind.FIELD and + not self.display_variables): + continue + # NOTE: This could be also a DocumentSymbol + symbol_range = symbol['location']['range'] + symbol_start = symbol_range['start']['line'] + symbol_end = symbol_range['end']['line'] + symbol_repr = SymbolStatus(symbol_name, symbol_kind, + (symbol_start, symbol_end), None) + tree_info.append((symbol_start, symbol_end + 1, symbol_repr)) + + tree = IntervalTree.from_tuples(tree_info) + changes = tree - current_tree + deleted = current_tree - tree + + if len(changes) == 0 and len(deleted) == 0: + self.sig_hide_spinner.emit() + return False + + adding_symbols = len(changes) > len(deleted) + deleted_iter = iter(sorted(deleted)) + changes_iter = iter(sorted(changes)) + + deleted_entry = next(deleted_iter, None) + changed_entry = next(changes_iter, None) + non_merged = 0 + + while deleted_entry is not None and changed_entry is not None: + deleted_entry_i = deleted_entry.data + changed_entry_i = changed_entry.data + + if deleted_entry_i.name == changed_entry_i.name: + # Copy symbol status + changed_entry_i.clone_node(deleted_entry_i) + deleted_entry = next(deleted_iter, None) + changed_entry = next(changes_iter, None) + else: + if adding_symbols: + # New symbol added + changed_entry_i.create_node() + non_merged += 1 + changed_entry = next(changes_iter, None) + else: + # Symbol removed + deleted_entry_i.delete() + non_merged += 1 + deleted_entry = next(deleted_iter, None) + + if deleted_entry is not None: + while deleted_entry is not None: + # Symbol removed + deleted_entry_i = deleted_entry.data + deleted_entry_i.delete() + non_merged += 1 + deleted_entry = next(deleted_iter, None) + + root = self.editor_items[editor_id] + # tree_merge + if changed_entry is not None: + while changed_entry is not None: + # New symbol added + changed_entry_i = changed_entry.data + changed_entry_i.create_node() + non_merged += 1 + changed_entry = next(changes_iter, None) + + tree_copy = IntervalTree(tree) + tree_copy.merge_overlaps( + data_reducer=self.merge_interval, data_initializer=root) + + self.editor_tree_cache[editor_id] = tree + self.sig_tree_updated.emit() + self.sig_hide_spinner.emit() + return True + + def remove_editor(self, editor): + if editor in self.editor_ids: + if self.current_editor is editor: + self.current_editor = None + editor_id = self.editor_ids.pop(editor) + if editor_id in self.ordered_editor_ids: + self.ordered_editor_ids.remove(editor_id) + if editor_id not in list(self.editor_ids.values()): + root_item = self.editor_items.pop(editor_id) + self.editor_tree_cache.pop(editor_id) + try: + self.takeTopLevelItem( + self.indexOfTopLevelItem(root_item.node)) + except RuntimeError: + # item has already been removed + pass + + def set_editor_ids_order(self, ordered_editor_ids): + """ + Order the root file items in the Outline Explorer following the + provided list of editor ids. + """ + if self.ordered_editor_ids != ordered_editor_ids: + self.ordered_editor_ids = ordered_editor_ids + if self.sort_files_alphabetically is False: + self.__sort_toplevel_items() + + def __sort_toplevel_items(self): + """ + Sort the root file items in alphabetical order if + 'sort_files_alphabetically' is True, else order the items as + specified in the 'self.ordered_editor_ids' list. + """ + if self.show_all_files is False: + return + + current_ordered_items = [self.topLevelItem(index) for index in + range(self.topLevelItemCount())] + + # Convert list to a dictionary in order to remove duplicated entries + # when having multiple editors (splitted or in new windows). + # See spyder-ide/spyder#14646 + current_ordered_items_dict = { + item.path.lower(): item for item in current_ordered_items} + + if self.sort_files_alphabetically: + new_ordered_items = sorted( + current_ordered_items_dict.values(), + key=lambda item: osp.basename(item.path.lower())) + else: + new_ordered_items = [ + self.editor_items.get(e_id).node for e_id in + self.ordered_editor_ids if + self.editor_items.get(e_id) is not None] + + # PySide <= 5.15.0 doesn’t support == and != comparison for the data + # types inside the compared lists (see [1], [2]) + # + # [1] https://bugreports.qt.io/browse/PYSIDE-74 + # [2] https://codereview.qt-project.org/c/pyside/pyside-setup/+/312945 + update = ( + (PYSIDE2 and parse_version(PYSIDE_VERSION) <= parse_version("5.15.0")) + or (current_ordered_items != new_ordered_items) + ) + if update: + selected_items = self.selectedItems() + self.save_expanded_state() + for index in range(self.topLevelItemCount()): + self.takeTopLevelItem(0) + for index, item in enumerate(new_ordered_items): + self.insertTopLevelItem(index, item) + self.restore_expanded_state() + self.clearSelection() + if selected_items: + selected_items[-1].setSelected(True) + + def root_item_selected(self, item): + """Root item has been selected: expanding it and collapsing others""" + if self.show_all_files: + return + for root_item in self.get_top_level_items(): + if root_item is item: + self.expandItem(root_item) + else: + self.collapseItem(root_item) + + def restore(self): + """Reimplemented OneColumnTree method""" + if self.current_editor is not None: + self.collapseAll() + editor_id = self.editor_ids[self.current_editor] + self.root_item_selected(self.editor_items[editor_id].node) + + def get_root_item(self, item): + """Return the root item of the specified item.""" + root_item = item + while isinstance(root_item.parent(), QTreeWidgetItem): + root_item = root_item.parent() + return root_item + + def get_visible_items(self): + """Return a list of all visible items in the treewidget.""" + items = [] + iterator = QTreeWidgetItemIterator(self) + while iterator.value(): + item = iterator.value() + if not item.isHidden(): + if item.parent(): + if item.parent().isExpanded(): + items.append(item) + else: + items.append(item) + iterator += 1 + return items + + def activated(self, item): + """Double-click event""" + editor_root = self.editor_items.get( + self.editor_ids.get(self.current_editor)) + root_item = editor_root.node + text = '' + if isinstance(item, FileRootItem): + line = None + if id(root_item) != id(item): + root_item = item + else: + line = item.ref.position[0] + 1 + text = item.ref.name + + path = item.ref.path + self.freeze = True + if line: + self.parent().edit_goto.emit(path, line, text) + else: + self.parent().edit.emit(path) + self.freeze = False + + for editor_id, i_item in list(self.editor_items.items()): + if i_item.path == path: + for editor, _id in list(self.editor_ids.items()): + self.current_editor = editor + break + break + + def clicked(self, item): + """Click event""" + if isinstance(item, FileRootItem): + self.root_item_selected(item) + self.activated(item) + + def selection_switched(self, current_item, previous_item): + if current_item is not None: + current_ref = current_item.ref + current_ref.selected = True + if previous_item is not None: + previous_ref = previous_item.ref + previous_ref.selected = False + + def tree_item_collapsed(self, item): + ref = item.ref + ref.status = False + + def tree_item_expanded(self, item): + ref = item.ref + ref.status = True + + def set_editors_to_update(self, language, reset_info=False): + """Set editors to update per language.""" + to_update = [] + for editor in self.editor_ids.keys(): + if editor.get_language().lower() == language: + to_update.append(editor) + if reset_info: + editor.info = None + self.editors_to_update[language] = to_update + + def start_symbol_services(self, language): + """Show symbols for all `language` files.""" + # Save all languages that can send info to this pane. + self._languages.append(language) + + # Update all files associated to `language` through a timer + # that allows to wait a bit between updates. That doesn't block + # the interface at startup. + timer = QTimer(self) + timer.setSingleShot(True) + timer.setInterval(700) + timer.timeout.connect(lambda: self.update_editors(language)) + self.update_timers[language] = timer + + # Set editors that need to be updated per language + self.set_editors_to_update(language) + + # Start timer + timer.start() + + def stop_symbol_services(self, language): + """Disable LSP symbols functionality.""" + try: + self._languages.remove(language) + except ValueError: + pass + + for editor in self.editor_ids.keys(): + if editor.get_language().lower() == language: + editor.info = None diff --git a/spyder/plugins/preferences/api.py b/spyder/plugins/preferences/api.py index b15c3c5444a..d9886f9006c 100644 --- a/spyder/plugins/preferences/api.py +++ b/spyder/plugins/preferences/api.py @@ -1,892 +1,892 @@ -# -*- 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 -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) - 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: - 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) - 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()): - rich_font = True if "rich" in option.lower() else False - font = self.get_font(rich_font) - fontbox.setCurrentFont(font) - sizebox.setValue(font.pointSize()) - if option is None: - property = 'plugin_font' - else: - property = option - fontbox.currentIndexChanged.connect(lambda _foo, opt=property: - self.has_been_modified( - self.CONF_SECTION, opt)) - sizebox.valueChanged.connect(lambda _foo, opt=property: - self.has_been_modified( - self.CONF_SECTION, opt)) - for clayout, (sec, option, default) in list(self.coloredits.items()): - property = to_qvariant(option) - 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 (self.CONF_SECTION, 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 create_checkbox(self, text, option, default=NoDefault, - tip=None, msg_warning=None, msg_info=None, - msg_if_enabled=False, section=None, restart=False): - checkbox = QCheckBox(text) - self.checkboxes[checkbox] = (section, option, default) - if section is not None and section != self.CONF_SECTION: - self.cross_section_options[option] = section - if tip is not None: - checkbox.setToolTip(tip) - 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 - return checkbox - - 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): - radiobutton = QRadioButton(text) - 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) - if tip is not None: - radiobutton.setToolTip(tip) - 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 - return radiobutton - - 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 tip: - edit.setToolTip(tip) - 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 - 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) - if tip: - edit.setToolTip(tip) - self.textedits[edit] = (section, option, default) - widget = QWidget(self) - widget.label = label - widget.textbox = edit - 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) - 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) - 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_) - if tip is not None: - spinbox.setToolTip(tip) - 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 - 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) - if tip is not None: - clayout.setToolTip(tip) - 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) - 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) - if tip is not None: - clayout.setToolTip(tip) - 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) - 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() - if tip is not None: - combobox.setToolTip(tip) - 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 - 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) - - if tip is not None: - combobox.setToolTip(tip) - 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 - widget.setLayout(layout) - - return widget - - def create_fontgroup(self, option=None, text=None, title=None, - tip=None, fontfilters=None, without_group=False): - """Option=None -> setting plugin font""" - - if title: - fontlabel = QLabel(title) - else: - fontlabel = QLabel(_("Font")) - fontbox = QFontComboBox() - - if fontfilters is not None: - fontbox.setFontFilters(fontfilters) - - sizelabel = QLabel(" " + _("Size")) - sizebox = QSpinBox() - sizebox.setRange(7, 100) - self.fontboxes[(fontbox, sizebox)] = option - layout = QHBoxLayout() - - for subwidget in (fontlabel, fontbox, sizelabel, sizebox): - layout.addWidget(subwidget) - layout.addStretch(1) - - widget = QWidget(self) - widget.fontlabel = fontlabel - widget.sizelabel = sizelabel - widget.fontbox = fontbox - widget.sizebox = sizebox - widget.setLayout(layout) - - if not without_group: - if text is None: - text = _("Font style") - - group = QGroupBox(text) - group.setLayout(layout) - - if tip is not None: - group.setToolTip(tip) - - return group - else: - 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.""" - restart_opts = self.restart_options - changed_opts = self.changed_options - options = [restart_opts[o] for o in changed_opts if o in restart_opts] - - if len(options) == 1: - msg_start = _("Spyder needs to restart to change the following " - "setting:") - else: - msg_start = _("Spyder needs to restart to change the following " - "settings:") - msg_end = _("Do you wish to restart now?") - - msg_options = u"" - for option in options: - msg_options += u"
  • {0}
  • ".format(option) - - msg_title = _("Information") - msg = u"{0}
      {1}

    {2}".format(msg_start, msg_options, msg_end) - answer = QMessageBox.information(self, msg_title, msg, - 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' +# -*- 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 +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) + 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: + 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) + 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()): + rich_font = True if "rich" in option.lower() else False + font = self.get_font(rich_font) + fontbox.setCurrentFont(font) + sizebox.setValue(font.pointSize()) + if option is None: + property = 'plugin_font' + else: + property = option + fontbox.currentIndexChanged.connect(lambda _foo, opt=property: + self.has_been_modified( + self.CONF_SECTION, opt)) + sizebox.valueChanged.connect(lambda _foo, opt=property: + self.has_been_modified( + self.CONF_SECTION, opt)) + for clayout, (sec, option, default) in list(self.coloredits.items()): + property = to_qvariant(option) + 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 (self.CONF_SECTION, 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 create_checkbox(self, text, option, default=NoDefault, + tip=None, msg_warning=None, msg_info=None, + msg_if_enabled=False, section=None, restart=False): + checkbox = QCheckBox(text) + self.checkboxes[checkbox] = (section, option, default) + if section is not None and section != self.CONF_SECTION: + self.cross_section_options[option] = section + if tip is not None: + checkbox.setToolTip(tip) + 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 + return checkbox + + 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): + radiobutton = QRadioButton(text) + 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) + if tip is not None: + radiobutton.setToolTip(tip) + 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 + return radiobutton + + 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 tip: + edit.setToolTip(tip) + 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 + 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) + if tip: + edit.setToolTip(tip) + self.textedits[edit] = (section, option, default) + widget = QWidget(self) + widget.label = label + widget.textbox = edit + 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) + 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) + 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_) + if tip is not None: + spinbox.setToolTip(tip) + 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 + 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) + if tip is not None: + clayout.setToolTip(tip) + 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) + 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) + if tip is not None: + clayout.setToolTip(tip) + 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) + 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() + if tip is not None: + combobox.setToolTip(tip) + 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 + 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) + + if tip is not None: + combobox.setToolTip(tip) + 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 + widget.setLayout(layout) + + return widget + + def create_fontgroup(self, option=None, text=None, title=None, + tip=None, fontfilters=None, without_group=False): + """Option=None -> setting plugin font""" + + if title: + fontlabel = QLabel(title) + else: + fontlabel = QLabel(_("Font")) + fontbox = QFontComboBox() + + if fontfilters is not None: + fontbox.setFontFilters(fontfilters) + + sizelabel = QLabel(" " + _("Size")) + sizebox = QSpinBox() + sizebox.setRange(7, 100) + self.fontboxes[(fontbox, sizebox)] = option + layout = QHBoxLayout() + + for subwidget in (fontlabel, fontbox, sizelabel, sizebox): + layout.addWidget(subwidget) + layout.addStretch(1) + + widget = QWidget(self) + widget.fontlabel = fontlabel + widget.sizelabel = sizelabel + widget.fontbox = fontbox + widget.sizebox = sizebox + widget.setLayout(layout) + + if not without_group: + if text is None: + text = _("Font style") + + group = QGroupBox(text) + group.setLayout(layout) + + if tip is not None: + group.setToolTip(tip) + + return group + else: + 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.""" + restart_opts = self.restart_options + changed_opts = self.changed_options + options = [restart_opts[o] for o in changed_opts if o in restart_opts] + + if len(options) == 1: + msg_start = _("Spyder needs to restart to change the following " + "setting:") + else: + msg_start = _("Spyder needs to restart to change the following " + "settings:") + msg_end = _("Do you wish to restart now?") + + msg_options = u"" + for option in options: + msg_options += u"
  • {0}
  • ".format(option) + + msg_title = _("Information") + msg = u"{0}
      {1}

    {2}".format(msg_start, msg_options, msg_end) + answer = QMessageBox.information(self, msg_title, msg, + 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' diff --git a/spyder/plugins/profiler/widgets/main_widget.py b/spyder/plugins/profiler/widgets/main_widget.py index 6c92d4ddb80..608757b0daf 100644 --- a/spyder/plugins/profiler/widgets/main_widget.py +++ b/spyder/plugins/profiler/widgets/main_widget.py @@ -1,1060 +1,1060 @@ -# -*- coding: utf-8 -*- -# -# Copyright © Spyder Project Contributors -# based on pylintgui.py by Pierre Raybaut -# -# Licensed under the terms of the MIT License -# (see spyder/__init__.py for details) - -""" -Profiler widget. - -See the official documentation on python profiling: -https://docs.python.org/3/library/profile.html -""" - -# Standard library imports -import logging -import os -import os.path as osp -import re -import sys -import time -from itertools import islice - -# Third party imports -from qtpy import PYQT5 -from qtpy.compat import getopenfilename, getsavefilename -from qtpy.QtCore import QByteArray, QProcess, QProcessEnvironment, Qt, Signal -from qtpy.QtGui import QColor -from qtpy.QtWidgets import (QApplication, QLabel, QMessageBox, QTreeWidget, - QTreeWidgetItem, QVBoxLayout) - -# Local imports -from spyder.api.translations import get_translation -from spyder.api.widgets.main_widget import PluginMainWidget -from spyder.api.widgets.mixins import SpyderWidgetMixin -from spyder.config.base import get_conf_path, running_in_mac_app -from spyder.plugins.variableexplorer.widgets.texteditor import TextEditor -from spyder.py3compat import to_text_string -from spyder.utils.misc import get_python_executable, getcwd_or_home -from spyder.utils.palette import SpyderPalette, QStylePalette -from spyder.utils.programs import shell_split -from spyder.utils.qthelpers import get_item_user_text, set_item_user_text -from spyder.widgets.comboboxes import PythonModulesComboBox - -# Localization -_ = get_translation('spyder') - -# Logging -logger = logging.getLogger(__name__) - - -# --- Constants -# ---------------------------------------------------------------------------- -MAIN_TEXT_COLOR = QStylePalette.COLOR_TEXT_1 - - -class ProfilerWidgetActions: - # Triggers - Browse = 'browse_action' - Clear = 'clear_action' - Collapse = 'collapse_action' - Expand = 'expand_action' - LoadData = 'load_data_action' - Run = 'run_action' - SaveData = 'save_data_action' - ShowOutput = 'show_output_action' - - -class ProfilerWidgetToolbars: - Information = 'information_toolbar' - - -class ProfilerWidgetMainToolbarSections: - Main = 'main_section' - - -class ProfilerWidgetInformationToolbarSections: - Main = 'main_section' - - -class ProfilerWidgetMainToolbarItems: - FileCombo = 'file_combo' - - -class ProfilerWidgetInformationToolbarItems: - Stretcher1 = 'stretcher_1' - Stretcher2 = 'stretcher_2' - DateLabel = 'date_label' - - -# --- Utils -# ---------------------------------------------------------------------------- -def is_profiler_installed(): - from spyder.utils.programs import is_module_installed - return is_module_installed('cProfile') and is_module_installed('pstats') - - -def gettime_s(text): - """ - Parse text and return a time in seconds. - - The text is of the format 0h : 0.min:0.0s:0 ms:0us:0 ns. - Spaces are not taken into account and any of the specifiers can be ignored. - """ - pattern = r'([+-]?\d+\.?\d*) ?([mμnsinh]+)' - matches = re.findall(pattern, text) - if len(matches) == 0: - return None - time = 0. - for res in matches: - tmp = float(res[0]) - if res[1] == 'ns': - tmp *= 1e-9 - elif res[1] == u'\u03BCs': - tmp *= 1e-6 - elif res[1] == 'ms': - tmp *= 1e-3 - elif res[1] == 'min': - tmp *= 60 - elif res[1] == 'h': - tmp *= 3600 - time += tmp - return time - - -# --- Widgets -# ---------------------------------------------------------------------------- -class ProfilerWidget(PluginMainWidget): - """ - Profiler widget. - """ - ENABLE_SPINNER = True - DATAPATH = get_conf_path('profiler.results') - - # --- Signals - # ------------------------------------------------------------------------ - sig_edit_goto_requested = Signal(str, int, str) - """ - This signal will request to open a file in a given row and column - using a code editor. - - Parameters - ---------- - path: str - Path to file. - row: int - Cursor starting row position. - word: str - Word to select on given row. - """ - - sig_redirect_stdio_requested = Signal(bool) - """ - This signal is emitted to request the main application to redirect - standard output/error when using Open/Save/Browse dialogs within widgets. - - Parameters - ---------- - redirect: bool - Start redirect (True) or stop redirect (False). - """ - - sig_started = Signal() - """This signal is emitted to inform the profiling process has started.""" - - sig_finished = Signal() - """This signal is emitted to inform the profile profiling has finished.""" - - def __init__(self, name=None, plugin=None, parent=None): - super().__init__(name, plugin, parent) - self.set_conf('text_color', MAIN_TEXT_COLOR) - - # Attributes - self._last_wdir = None - self._last_args = None - self._last_pythonpath = None - self.error_output = None - self.output = None - self.running = False - self.text_color = self.get_conf('text_color') - - # Widgets - self.process = None - self.filecombo = PythonModulesComboBox( - self, id_=ProfilerWidgetMainToolbarItems.FileCombo) - self.datatree = ProfilerDataTree(self) - self.datelabel = QLabel() - self.datelabel.ID = ProfilerWidgetInformationToolbarItems.DateLabel - - # Layout - layout = QVBoxLayout() - layout.addWidget(self.datatree) - self.setLayout(layout) - - # Signals - self.datatree.sig_edit_goto_requested.connect( - self.sig_edit_goto_requested) - - # --- PluginMainWidget API - # ------------------------------------------------------------------------ - def get_title(self): - return _('Profiler') - - def get_focus_widget(self): - return self.datatree - - def setup(self): - self.start_action = self.create_action( - ProfilerWidgetActions.Run, - text=_("Run profiler"), - tip=_("Run profiler"), - icon=self.create_icon('run'), - triggered=self.run, - ) - browse_action = self.create_action( - ProfilerWidgetActions.Browse, - text='', - tip=_('Select Python file'), - icon=self.create_icon('fileopen'), - triggered=lambda x: self.select_file(), - ) - self.log_action = self.create_action( - ProfilerWidgetActions.ShowOutput, - text=_("Output"), - tip=_("Show program's output"), - icon=self.create_icon('log'), - triggered=self.show_log, - ) - self.collapse_action = self.create_action( - ProfilerWidgetActions.Collapse, - text=_('Collapse'), - tip=_('Collapse one level up'), - icon=self.create_icon('collapse'), - triggered=lambda x=None: self.datatree.change_view(-1), - ) - self.expand_action = self.create_action( - ProfilerWidgetActions.Expand, - text=_('Expand'), - tip=_('Expand one level down'), - icon=self.create_icon('expand'), - triggered=lambda x=None: self.datatree.change_view(1), - ) - self.save_action = self.create_action( - ProfilerWidgetActions.SaveData, - text=_("Save data"), - tip=_('Save profiling data'), - icon=self.create_icon('filesave'), - triggered=self.save_data, - ) - self.load_action = self.create_action( - ProfilerWidgetActions.LoadData, - text=_("Load data"), - tip=_('Load profiling data for comparison'), - icon=self.create_icon('fileimport'), - triggered=self.compare, - ) - self.clear_action = self.create_action( - ProfilerWidgetActions.Clear, - text=_("Clear comparison"), - tip=_("Clear comparison"), - icon=self.create_icon('editdelete'), - triggered=self.clear, - ) - self.clear_action.setEnabled(False) - - # Main Toolbar - toolbar = self.get_main_toolbar() - for item in [self.filecombo, browse_action, self.start_action]: - self.add_item_to_toolbar( - item, - toolbar=toolbar, - section=ProfilerWidgetMainToolbarSections.Main, - ) - - # Secondary Toolbar - secondary_toolbar = self.create_toolbar( - ProfilerWidgetToolbars.Information) - for item in [self.collapse_action, self.expand_action, - self.create_stretcher( - id_=ProfilerWidgetInformationToolbarItems.Stretcher1), - self.datelabel, - self.create_stretcher( - id_=ProfilerWidgetInformationToolbarItems.Stretcher2), - self.log_action, - self.save_action, self.load_action, self.clear_action]: - self.add_item_to_toolbar( - item, - toolbar=secondary_toolbar, - section=ProfilerWidgetInformationToolbarSections.Main, - ) - - # Setup - if not is_profiler_installed(): - # This should happen only on certain GNU/Linux distributions - # or when this a home-made Python build because the Python - # profilers are included in the Python standard library - for widget in (self.datatree, self.filecombo, - self.start_action): - widget.setDisabled(True) - url = 'https://docs.python.org/3/library/profile.html' - text = '%s %s' % (_('Please install'), url, - _("the Python profiler modules")) - self.datelabel.setText(text) - - def update_actions(self): - if self.running: - icon = self.create_icon('stop') - else: - icon = self.create_icon('run') - self.start_action.setIcon(icon) - - self.start_action.setEnabled(bool(self.filecombo.currentText())) - - # --- Private API - # ------------------------------------------------------------------------ - def _kill_if_running(self): - """Kill the profiling process if it is running.""" - if self.process is not None: - if self.process.state() == QProcess.Running: - self.process.close() - self.process.waitForFinished(1000) - - self.update_actions() - - def _finished(self, exit_code, exit_status): - """ - Parse results once the profiling process has ended. - - Parameters - ---------- - exit_code: int - QProcess exit code. - exit_status: str - QProcess exit status. - """ - self.running = False - self.show_errorlog() # If errors occurred, show them. - self.output = self.error_output + self.output - self.datelabel.setText('') - self.show_data(justanalyzed=True) - self.update_actions() - - def _read_output(self, error=False): - """ - Read otuput from QProcess. - - Parameters - ---------- - error: bool, optional - Process QProcess output or error channels. Default is False. - """ - if error: - self.process.setReadChannel(QProcess.StandardError) - else: - self.process.setReadChannel(QProcess.StandardOutput) - - qba = QByteArray() - while self.process.bytesAvailable(): - if error: - qba += self.process.readAllStandardError() - else: - qba += self.process.readAllStandardOutput() - - text = to_text_string(qba.data(), encoding='utf-8') - if error: - self.error_output += text - else: - self.output += text - - # --- Public API - # ------------------------------------------------------------------------ - def save_data(self): - """Save data.""" - title = _( "Save profiler result") - filename, _selfilter = getsavefilename( - self, - title, - getcwd_or_home(), - _("Profiler result") + " (*.Result)", - ) - - if filename: - self.datatree.save_data(filename) - - def compare(self): - """Compare previous saved run with last run.""" - filename, _selfilter = getopenfilename( - self, - _("Select script to compare"), - getcwd_or_home(), - _("Profiler result") + " (*.Result)", - ) - - if filename: - self.datatree.compare(filename) - self.show_data() - self.clear_action.setEnabled(True) - - def clear(self): - """Clear data in tree.""" - self.datatree.compare(None) - self.datatree.hide_diff_cols(True) - self.show_data() - self.clear_action.setEnabled(False) - - def analyze(self, filename, wdir=None, args=None, pythonpath=None): - """ - Start the profiling process. - - Parameters - ---------- - wdir: str - Working directory path string. Default is None. - args: list - Arguments to pass to the profiling process. Default is None. - pythonpath: str - Python path string. Default is None. - """ - if not is_profiler_installed(): - return - - self._kill_if_running() - - # TODO: storing data is not implemented yet - # index, _data = self.get_data(filename) - combo = self.filecombo - items = [combo.itemText(idx) for idx in range(combo.count())] - index = None - if index is None and filename not in items: - self.filecombo.addItem(filename) - self.filecombo.setCurrentIndex(self.filecombo.count() - 1) - else: - self.filecombo.setCurrentIndex(self.filecombo.findText(filename)) - - self.filecombo.selected() - if self.filecombo.is_valid(): - if wdir is None: - wdir = osp.dirname(filename) - - self.start(wdir, args, pythonpath) - - def select_file(self, filename=None): - """ - Select filename to profile. - - Parameters - ---------- - filename: str, optional - Path to filename to profile. default is None. - - Notes - ----- - If no `filename` is provided an open filename dialog will be used. - """ - if filename is None: - self.sig_redirect_stdio_requested.emit(False) - filename, _selfilter = getopenfilename( - self, - _("Select Python file"), - getcwd_or_home(), - _("Python files") + " (*.py ; *.pyw)" - ) - self.sig_redirect_stdio_requested.emit(True) - - if filename: - self.analyze(filename) - - def show_log(self): - """Show process output log.""" - if self.output: - output_dialog = TextEditor( - self.output, - title=_("Profiler output"), - readonly=True, - parent=self, - ) - output_dialog.resize(700, 500) - output_dialog.exec_() - - def show_errorlog(self): - """Show process error log.""" - if self.error_output: - output_dialog = TextEditor( - self.error_output, - title=_("Profiler output"), - readonly=True, - parent=self, - ) - output_dialog.resize(700, 500) - output_dialog.exec_() - - def start(self, wdir=None, args=None, pythonpath=None): - """ - Start the profiling process. - - Parameters - ---------- - wdir: str - Working directory path string. Default is None. - args: list - Arguments to pass to the profiling process. Default is None. - pythonpath: str - Python path string. Default is None. - """ - filename = to_text_string(self.filecombo.currentText()) - if wdir is None: - wdir = self._last_wdir - if wdir is None: - wdir = osp.basename(filename) - - if args is None: - args = self._last_args - if args is None: - args = [] - - if pythonpath is None: - pythonpath = self._last_pythonpath - - self._last_wdir = wdir - self._last_args = args - self._last_pythonpath = pythonpath - - self.datelabel.setText(_('Profiling, please wait...')) - - self.process = QProcess(self) - self.process.setProcessChannelMode(QProcess.SeparateChannels) - self.process.setWorkingDirectory(wdir) - self.process.readyReadStandardOutput.connect(self._read_output) - self.process.readyReadStandardError.connect( - lambda: self._read_output(error=True)) - self.process.finished.connect( - lambda ec, es=QProcess.ExitStatus: self._finished(ec, es)) - self.process.finished.connect(self.stop_spinner) - - # Start with system environment - proc_env = QProcessEnvironment() - for k, v in os.environ.items(): - proc_env.insert(k, v) - proc_env.insert("PYTHONIOENCODING", "utf8") - proc_env.remove('PYTHONPATH') - if pythonpath is not None: - proc_env.insert('PYTHONPATH', os.pathsep.join(pythonpath)) - self.process.setProcessEnvironment(proc_env) - - executable = self.get_conf('executable', section='main_interpreter') - - if not running_in_mac_app(executable): - env = self.process.processEnvironment() - env.remove('PYTHONHOME') - self.process.setProcessEnvironment(env) - - self.output = '' - self.error_output = '' - self.running = True - self.start_spinner() - - p_args = ['-m', 'cProfile', '-o', self.DATAPATH] - if os.name == 'nt': - # On Windows, one has to replace backslashes by slashes to avoid - # confusion with escape characters (otherwise, for example, '\t' - # will be interpreted as a tabulation): - p_args.append(osp.normpath(filename).replace(os.sep, '/')) - else: - p_args.append(filename) - - if args: - p_args.extend(shell_split(args)) - - self.process.start(executable, p_args) - running = self.process.waitForStarted() - if not running: - QMessageBox.critical( - self, - _("Error"), - _("Process failed to start"), - ) - self.update_actions() - - def stop(self): - """Stop the running process.""" - self.running = False - self.process.close() - self.process.waitForFinished(1000) - self.stop_spinner() - self.update_actions() - - def run(self): - """Toggle starting or running the profiling process.""" - if self.running: - self.stop() - else: - self.start() - - def show_data(self, justanalyzed=False): - """ - Show analyzed data on results tree. - - Parameters - ---------- - justanalyzed: bool, optional - Default is False. - """ - if not justanalyzed: - self.output = None - - self.log_action.setEnabled(self.output is not None - and len(self.output) > 0) - self._kill_if_running() - filename = to_text_string(self.filecombo.currentText()) - if not filename: - return - - self.datelabel.setText(_('Sorting data, please wait...')) - QApplication.processEvents() - - self.datatree.load_data(self.DATAPATH) - self.datatree.show_tree() - - text_style = "%s " - date_text = text_style % (self.text_color, - time.strftime("%Y-%m-%d %H:%M:%S", - time.localtime())) - self.datelabel.setText(date_text) - - -class TreeWidgetItem(QTreeWidgetItem): - def __init__(self, parent=None): - QTreeWidgetItem.__init__(self, parent) - - def __lt__(self, otherItem): - column = self.treeWidget().sortColumn() - try: - if column == 1 or column == 3: # TODO: Hardcoded Column - t0 = gettime_s(self.text(column)) - t1 = gettime_s(otherItem.text(column)) - if t0 is not None and t1 is not None: - return t0 > t1 - - return float(self.text(column)) > float(otherItem.text(column)) - except ValueError: - return self.text(column) > otherItem.text(column) - - -class ProfilerDataTree(QTreeWidget, SpyderWidgetMixin): - """ - Convenience tree widget (with built-in model) - to store and view profiler data. - - The quantities calculated by the profiler are as follows - (from profile.Profile): - [0] = The number of times this function was called, not counting direct - or indirect recursion, - [1] = Number of times this function appears on the stack, minus one - [2] = Total time spent internal to this function - [3] = Cumulative time that this function was present on the stack. In - non-recursive functions, this is the total execution time from start - to finish of each invocation of a function, including time spent in - all subfunctions. - [4] = A dictionary indicating for each function name, the number of times - it was called by us. - """ - SEP = r"<[=]>" # separator between filename and linenumber - # (must be improbable as a filename to avoid splitting the filename itself) - - # Signals - sig_edit_goto_requested = Signal(str, int, str) - - def __init__(self, parent=None): - if PYQT5: - super().__init__(parent, class_parent=parent) - else: - QTreeWidget.__init__(self, parent) - SpyderWidgetMixin.__init__(self, class_parent=parent) - - self.header_list = [_('Function/Module'), _('Total Time'), _('Diff'), - _('Local Time'), _('Diff'), _('Calls'), _('Diff'), - _('File:line')] - self.icon_list = { - 'module': self.create_icon('python'), - 'function': self.create_icon('function'), - 'builtin': self.create_icon('python'), - 'constructor': self.create_icon('class') - } - self.profdata = None # To be filled by self.load_data() - self.stats = None # To be filled by self.load_data() - self.item_depth = None - self.item_list = None - self.items_to_be_shown = None - self.current_view_depth = None - self.compare_file = None - self.setColumnCount(len(self.header_list)) - self.setHeaderLabels(self.header_list) - self.initialize_view() - self.itemActivated.connect(self.item_activated) - self.itemExpanded.connect(self.item_expanded) - - def set_item_data(self, item, filename, line_number): - """Set tree item user data: filename (string) and line_number (int)""" - set_item_user_text(item, '%s%s%d' % (filename, self.SEP, line_number)) - - def get_item_data(self, item): - """Get tree item user data: (filename, line_number)""" - filename, line_number_str = get_item_user_text(item).split(self.SEP) - return filename, int(line_number_str) - - def initialize_view(self): - """Clean the tree and view parameters""" - self.clear() - self.item_depth = 0 # To be use for collapsing/expanding one level - self.item_list = [] # To be use for collapsing/expanding one level - self.items_to_be_shown = {} - self.current_view_depth = 0 - - def load_data(self, profdatafile): - """Load profiler data saved by profile/cProfile module""" - import pstats - # Fixes spyder-ide/spyder#6220. - try: - stats_indi = [pstats.Stats(profdatafile), ] - except (OSError, IOError): - self.profdata = None - return - self.profdata = stats_indi[0] - - if self.compare_file is not None: - # Fixes spyder-ide/spyder#5587. - try: - stats_indi.append(pstats.Stats(self.compare_file)) - except (OSError, IOError) as e: - QMessageBox.critical( - self, _("Error"), - _("Error when trying to load profiler results. " - "The error was

    " - "{0}").format(e)) - self.compare_file = None - map(lambda x: x.calc_callees(), stats_indi) - self.profdata.calc_callees() - self.stats1 = stats_indi - self.stats = stats_indi[0].stats - - def compare(self, filename): - self.hide_diff_cols(False) - self.compare_file = filename - - def hide_diff_cols(self, hide): - for i in (2, 4, 6): - self.setColumnHidden(i, hide) - - def save_data(self, filename): - """Save profiler data.""" - self.stats1[0].dump_stats(filename) - - def find_root(self): - """Find a function without a caller""" - # Fixes spyder-ide/spyder#8336. - if self.profdata is not None: - self.profdata.sort_stats("cumulative") - else: - return - for func in self.profdata.fcn_list: - if ('~', 0) != func[0:2] and not func[2].startswith( - ''): - # This skips the profiler function at the top of the list - # it does only occur in Python 3 - return func - - def find_callees(self, parent): - """Find all functions called by (parent) function.""" - # FIXME: This implementation is very inneficient, because it - # traverses all the data to find children nodes (callees) - return self.profdata.all_callees[parent] - - def show_tree(self): - """Populate the tree with profiler data and display it.""" - self.initialize_view() # Clear before re-populating - self.setItemsExpandable(True) - self.setSortingEnabled(False) - rootkey = self.find_root() # This root contains profiler overhead - if rootkey is not None: - self.populate_tree(self, self.find_callees(rootkey)) - self.resizeColumnToContents(0) - self.setSortingEnabled(True) - self.sortItems(1, Qt.AscendingOrder) # FIXME: hardcoded index - self.change_view(1) - - def function_info(self, functionKey): - """Returns processed information about the function's name and file.""" - node_type = 'function' - filename, line_number, function_name = functionKey - if function_name == '': - modulePath, moduleName = osp.split(filename) - node_type = 'module' - if moduleName == '__init__.py': - modulePath, moduleName = osp.split(modulePath) - function_name = '<' + moduleName + '>' - if not filename or filename == '~': - file_and_line = '(built-in)' - node_type = 'builtin' - else: - if function_name == '__init__': - node_type = 'constructor' - file_and_line = '%s : %d' % (filename, line_number) - return filename, line_number, function_name, file_and_line, node_type - - @staticmethod - def format_measure(measure): - """Get format and units for data coming from profiler task.""" - # Convert to a positive value. - measure = abs(measure) - - # For number of calls - if isinstance(measure, int): - return to_text_string(measure) - - # For time measurements - if 1.e-9 < measure <= 1.e-6: - measure = u"{0:.2f} ns".format(measure / 1.e-9) - elif 1.e-6 < measure <= 1.e-3: - measure = u"{0:.2f} \u03BCs".format(measure / 1.e-6) - elif 1.e-3 < measure <= 1: - measure = u"{0:.2f} ms".format(measure / 1.e-3) - elif 1 < measure <= 60: - measure = u"{0:.2f} s".format(measure) - elif 60 < measure <= 3600: - m, s = divmod(measure, 3600) - if s > 60: - m, s = divmod(measure, 60) - s = to_text_string(s).split(".")[-1] - measure = u"{0:.0f}.{1:.2s} min".format(m, s) - else: - h, m = divmod(measure, 3600) - if m > 60: - m /= 60 - measure = u"{0:.0f}h:{1:.0f}min".format(h, m) - return measure - - def color_string(self, x): - """Return a string formatted delta for the values in x. - - Args: - x: 2-item list of integers (representing number of calls) or - 2-item list of floats (representing seconds of runtime). - - Returns: - A list with [formatted x[0], [color, formatted delta]], where - color reflects whether x[1] is lower, greater, or the same as - x[0]. - """ - diff_str = "" - color = "black" - - if len(x) == 2 and self.compare_file is not None: - difference = x[0] - x[1] - if difference: - color, sign = ((SpyderPalette.COLOR_SUCCESS_1, '-') - if difference < 0 - else (SpyderPalette.COLOR_ERROR_1, '+')) - diff_str = '{}{}'.format(sign, self.format_measure(difference)) - return [self.format_measure(x[0]), [diff_str, color]] - - def format_output(self, child_key): - """ Formats the data. - - self.stats1 contains a list of one or two pstat.Stats() instances, with - the first being the current run and the second, the saved run, if it - exists. Each Stats instance is a dictionary mapping a function to - 5 data points - cumulative calls, number of calls, total time, - cumulative time, and callers. - - format_output() converts the number of calls, total time, and - cumulative time to a string format for the child_key parameter. - """ - data = [x.stats.get(child_key, [0, 0, 0, 0, {}]) for x in self.stats1] - return (map(self.color_string, islice(zip(*data), 1, 4))) - - def populate_tree(self, parentItem, children_list): - """ - Recursive method to create each item (and associated data) - in the tree. - """ - for child_key in children_list: - self.item_depth += 1 - (filename, line_number, function_name, file_and_line, node_type - ) = self.function_info(child_key) - - ((total_calls, total_calls_dif), (loc_time, loc_time_dif), - (cum_time, cum_time_dif)) = self.format_output(child_key) - - child_item = TreeWidgetItem(parentItem) - self.item_list.append(child_item) - self.set_item_data(child_item, filename, line_number) - - # FIXME: indexes to data should be defined by a dictionary on init - child_item.setToolTip(0, _('Function or module name')) - child_item.setData(0, Qt.DisplayRole, function_name) - child_item.setIcon(0, self.icon_list[node_type]) - - child_item.setToolTip(1, _('Time in function ' - '(including sub-functions)')) - child_item.setData(1, Qt.DisplayRole, cum_time) - child_item.setTextAlignment(1, Qt.AlignRight) - - child_item.setData(2, Qt.DisplayRole, cum_time_dif[0]) - child_item.setForeground(2, QColor(cum_time_dif[1])) - child_item.setTextAlignment(2, Qt.AlignLeft) - - child_item.setToolTip(3, _('Local time in function ' - '(not in sub-functions)')) - - child_item.setData(3, Qt.DisplayRole, loc_time) - child_item.setTextAlignment(3, Qt.AlignRight) - - child_item.setData(4, Qt.DisplayRole, loc_time_dif[0]) - child_item.setForeground(4, QColor(loc_time_dif[1])) - child_item.setTextAlignment(4, Qt.AlignLeft) - - child_item.setToolTip(5, _('Total number of calls ' - '(including recursion)')) - - child_item.setData(5, Qt.DisplayRole, total_calls) - child_item.setTextAlignment(5, Qt.AlignRight) - - child_item.setData(6, Qt.DisplayRole, total_calls_dif[0]) - child_item.setForeground(6, QColor(total_calls_dif[1])) - child_item.setTextAlignment(6, Qt.AlignLeft) - - child_item.setToolTip(7, _('File:line ' - 'where function is defined')) - child_item.setData(7, Qt.DisplayRole, file_and_line) - #child_item.setExpanded(True) - if self.is_recursive(child_item): - child_item.setData(7, Qt.DisplayRole, '(%s)' % _('recursion')) - child_item.setDisabled(True) - else: - callees = self.find_callees(child_key) - if self.item_depth < 3: - self.populate_tree(child_item, callees) - elif callees: - child_item.setChildIndicatorPolicy(child_item.ShowIndicator) - self.items_to_be_shown[id(child_item)] = callees - self.item_depth -= 1 - - def item_activated(self, item): - filename, line_number = self.get_item_data(item) - self.sig_edit_goto_requested.emit(filename, line_number, '') - - def item_expanded(self, item): - if item.childCount() == 0 and id(item) in self.items_to_be_shown: - callees = self.items_to_be_shown[id(item)] - self.populate_tree(item, callees) - - def is_recursive(self, child_item): - """Returns True is a function is a descendant of itself.""" - ancestor = child_item.parent() - # FIXME: indexes to data should be defined by a dictionary on init - while ancestor: - if (child_item.data(0, Qt.DisplayRole - ) == ancestor.data(0, Qt.DisplayRole) and - child_item.data(7, Qt.DisplayRole - ) == ancestor.data(7, Qt.DisplayRole)): - return True - else: - ancestor = ancestor.parent() - return False - - def get_top_level_items(self): - """Iterate over top level items""" - return [self.topLevelItem(_i) - for _i in range(self.topLevelItemCount())] - - def get_items(self, maxlevel): - """Return all items with a level <= `maxlevel`""" - itemlist = [] - - def add_to_itemlist(item, maxlevel, level=1): - level += 1 - for index in range(item.childCount()): - citem = item.child(index) - itemlist.append(citem) - if level <= maxlevel: - add_to_itemlist(citem, maxlevel, level) - - for tlitem in self.get_top_level_items(): - itemlist.append(tlitem) - if maxlevel > 0: - add_to_itemlist(tlitem, maxlevel=maxlevel) - return itemlist - - def change_view(self, change_in_depth): - """Change view depth by expanding or collapsing all same-level nodes""" - self.current_view_depth += change_in_depth - if self.current_view_depth < 0: - self.current_view_depth = 0 - self.collapseAll() - if self.current_view_depth > 0: - for item in self.get_items(maxlevel=self.current_view_depth - 1): - item.setExpanded(True) - - -# ============================================================================= -# Tests -# ============================================================================= -def primes(n): - """ - Simple test function - Taken from http://www.huyng.com/posts/python-performance-analysis/ - """ - if n == 2: - return [2] - elif n < 2: - return [] - s = list(range(3, n + 1, 2)) - mroot = n ** 0.5 - half = (n + 1) // 2 - 1 - i = 0 - m = 3 - while m <= mroot: - if s[i]: - j = (m * m - 3) // 2 - s[j] = 0 - while j < half: - s[j] = 0 - j += m - i = i + 1 - m = 2 * i + 3 - return [2] + [x for x in s if x] - - -def test(): - """Run widget test""" - from spyder.utils.qthelpers import qapplication - import inspect - import tempfile - from unittest.mock import MagicMock - - primes_sc = inspect.getsource(primes) - fd, script = tempfile.mkstemp(suffix='.py') - with os.fdopen(fd, 'w') as f: - f.write("# -*- coding: utf-8 -*-" + "\n\n") - f.write(primes_sc + "\n\n") - f.write("primes(100000)") - - plugin_mock = MagicMock() - plugin_mock.CONF_SECTION = 'profiler' - - app = qapplication(test_time=5) - widget = ProfilerWidget('test', plugin=plugin_mock) - widget._setup() - widget.setup() - widget.get_conf('executable', get_python_executable(), - section='main_interpreter') - widget.resize(800, 600) - widget.show() - widget.analyze(script) - sys.exit(app.exec_()) - - -if __name__ == '__main__': - test() +# -*- coding: utf-8 -*- +# +# Copyright © Spyder Project Contributors +# based on pylintgui.py by Pierre Raybaut +# +# Licensed under the terms of the MIT License +# (see spyder/__init__.py for details) + +""" +Profiler widget. + +See the official documentation on python profiling: +https://docs.python.org/3/library/profile.html +""" + +# Standard library imports +import logging +import os +import os.path as osp +import re +import sys +import time +from itertools import islice + +# Third party imports +from qtpy import PYQT5 +from qtpy.compat import getopenfilename, getsavefilename +from qtpy.QtCore import QByteArray, QProcess, QProcessEnvironment, Qt, Signal +from qtpy.QtGui import QColor +from qtpy.QtWidgets import (QApplication, QLabel, QMessageBox, QTreeWidget, + QTreeWidgetItem, QVBoxLayout) + +# Local imports +from spyder.api.translations import get_translation +from spyder.api.widgets.main_widget import PluginMainWidget +from spyder.api.widgets.mixins import SpyderWidgetMixin +from spyder.config.base import get_conf_path, running_in_mac_app +from spyder.plugins.variableexplorer.widgets.texteditor import TextEditor +from spyder.py3compat import to_text_string +from spyder.utils.misc import get_python_executable, getcwd_or_home +from spyder.utils.palette import SpyderPalette, QStylePalette +from spyder.utils.programs import shell_split +from spyder.utils.qthelpers import get_item_user_text, set_item_user_text +from spyder.widgets.comboboxes import PythonModulesComboBox + +# Localization +_ = get_translation('spyder') + +# Logging +logger = logging.getLogger(__name__) + + +# --- Constants +# ---------------------------------------------------------------------------- +MAIN_TEXT_COLOR = QStylePalette.COLOR_TEXT_1 + + +class ProfilerWidgetActions: + # Triggers + Browse = 'browse_action' + Clear = 'clear_action' + Collapse = 'collapse_action' + Expand = 'expand_action' + LoadData = 'load_data_action' + Run = 'run_action' + SaveData = 'save_data_action' + ShowOutput = 'show_output_action' + + +class ProfilerWidgetToolbars: + Information = 'information_toolbar' + + +class ProfilerWidgetMainToolbarSections: + Main = 'main_section' + + +class ProfilerWidgetInformationToolbarSections: + Main = 'main_section' + + +class ProfilerWidgetMainToolbarItems: + FileCombo = 'file_combo' + + +class ProfilerWidgetInformationToolbarItems: + Stretcher1 = 'stretcher_1' + Stretcher2 = 'stretcher_2' + DateLabel = 'date_label' + + +# --- Utils +# ---------------------------------------------------------------------------- +def is_profiler_installed(): + from spyder.utils.programs import is_module_installed + return is_module_installed('cProfile') and is_module_installed('pstats') + + +def gettime_s(text): + """ + Parse text and return a time in seconds. + + The text is of the format 0h : 0.min:0.0s:0 ms:0us:0 ns. + Spaces are not taken into account and any of the specifiers can be ignored. + """ + pattern = r'([+-]?\d+\.?\d*) ?([mμnsinh]+)' + matches = re.findall(pattern, text) + if len(matches) == 0: + return None + time = 0. + for res in matches: + tmp = float(res[0]) + if res[1] == 'ns': + tmp *= 1e-9 + elif res[1] == u'\u03BCs': + tmp *= 1e-6 + elif res[1] == 'ms': + tmp *= 1e-3 + elif res[1] == 'min': + tmp *= 60 + elif res[1] == 'h': + tmp *= 3600 + time += tmp + return time + + +# --- Widgets +# ---------------------------------------------------------------------------- +class ProfilerWidget(PluginMainWidget): + """ + Profiler widget. + """ + ENABLE_SPINNER = True + DATAPATH = get_conf_path('profiler.results') + + # --- Signals + # ------------------------------------------------------------------------ + sig_edit_goto_requested = Signal(str, int, str) + """ + This signal will request to open a file in a given row and column + using a code editor. + + Parameters + ---------- + path: str + Path to file. + row: int + Cursor starting row position. + word: str + Word to select on given row. + """ + + sig_redirect_stdio_requested = Signal(bool) + """ + This signal is emitted to request the main application to redirect + standard output/error when using Open/Save/Browse dialogs within widgets. + + Parameters + ---------- + redirect: bool + Start redirect (True) or stop redirect (False). + """ + + sig_started = Signal() + """This signal is emitted to inform the profiling process has started.""" + + sig_finished = Signal() + """This signal is emitted to inform the profile profiling has finished.""" + + def __init__(self, name=None, plugin=None, parent=None): + super().__init__(name, plugin, parent) + self.set_conf('text_color', MAIN_TEXT_COLOR) + + # Attributes + self._last_wdir = None + self._last_args = None + self._last_pythonpath = None + self.error_output = None + self.output = None + self.running = False + self.text_color = self.get_conf('text_color') + + # Widgets + self.process = None + self.filecombo = PythonModulesComboBox( + self, id_=ProfilerWidgetMainToolbarItems.FileCombo) + self.datatree = ProfilerDataTree(self) + self.datelabel = QLabel() + self.datelabel.ID = ProfilerWidgetInformationToolbarItems.DateLabel + + # Layout + layout = QVBoxLayout() + layout.addWidget(self.datatree) + self.setLayout(layout) + + # Signals + self.datatree.sig_edit_goto_requested.connect( + self.sig_edit_goto_requested) + + # --- PluginMainWidget API + # ------------------------------------------------------------------------ + def get_title(self): + return _('Profiler') + + def get_focus_widget(self): + return self.datatree + + def setup(self): + self.start_action = self.create_action( + ProfilerWidgetActions.Run, + text=_("Run profiler"), + tip=_("Run profiler"), + icon=self.create_icon('run'), + triggered=self.run, + ) + browse_action = self.create_action( + ProfilerWidgetActions.Browse, + text='', + tip=_('Select Python file'), + icon=self.create_icon('fileopen'), + triggered=lambda x: self.select_file(), + ) + self.log_action = self.create_action( + ProfilerWidgetActions.ShowOutput, + text=_("Output"), + tip=_("Show program's output"), + icon=self.create_icon('log'), + triggered=self.show_log, + ) + self.collapse_action = self.create_action( + ProfilerWidgetActions.Collapse, + text=_('Collapse'), + tip=_('Collapse one level up'), + icon=self.create_icon('collapse'), + triggered=lambda x=None: self.datatree.change_view(-1), + ) + self.expand_action = self.create_action( + ProfilerWidgetActions.Expand, + text=_('Expand'), + tip=_('Expand one level down'), + icon=self.create_icon('expand'), + triggered=lambda x=None: self.datatree.change_view(1), + ) + self.save_action = self.create_action( + ProfilerWidgetActions.SaveData, + text=_("Save data"), + tip=_('Save profiling data'), + icon=self.create_icon('filesave'), + triggered=self.save_data, + ) + self.load_action = self.create_action( + ProfilerWidgetActions.LoadData, + text=_("Load data"), + tip=_('Load profiling data for comparison'), + icon=self.create_icon('fileimport'), + triggered=self.compare, + ) + self.clear_action = self.create_action( + ProfilerWidgetActions.Clear, + text=_("Clear comparison"), + tip=_("Clear comparison"), + icon=self.create_icon('editdelete'), + triggered=self.clear, + ) + self.clear_action.setEnabled(False) + + # Main Toolbar + toolbar = self.get_main_toolbar() + for item in [self.filecombo, browse_action, self.start_action]: + self.add_item_to_toolbar( + item, + toolbar=toolbar, + section=ProfilerWidgetMainToolbarSections.Main, + ) + + # Secondary Toolbar + secondary_toolbar = self.create_toolbar( + ProfilerWidgetToolbars.Information) + for item in [self.collapse_action, self.expand_action, + self.create_stretcher( + id_=ProfilerWidgetInformationToolbarItems.Stretcher1), + self.datelabel, + self.create_stretcher( + id_=ProfilerWidgetInformationToolbarItems.Stretcher2), + self.log_action, + self.save_action, self.load_action, self.clear_action]: + self.add_item_to_toolbar( + item, + toolbar=secondary_toolbar, + section=ProfilerWidgetInformationToolbarSections.Main, + ) + + # Setup + if not is_profiler_installed(): + # This should happen only on certain GNU/Linux distributions + # or when this a home-made Python build because the Python + # profilers are included in the Python standard library + for widget in (self.datatree, self.filecombo, + self.start_action): + widget.setDisabled(True) + url = 'https://docs.python.org/3/library/profile.html' + text = '%s %s' % (_('Please install'), url, + _("the Python profiler modules")) + self.datelabel.setText(text) + + def update_actions(self): + if self.running: + icon = self.create_icon('stop') + else: + icon = self.create_icon('run') + self.start_action.setIcon(icon) + + self.start_action.setEnabled(bool(self.filecombo.currentText())) + + # --- Private API + # ------------------------------------------------------------------------ + def _kill_if_running(self): + """Kill the profiling process if it is running.""" + if self.process is not None: + if self.process.state() == QProcess.Running: + self.process.close() + self.process.waitForFinished(1000) + + self.update_actions() + + def _finished(self, exit_code, exit_status): + """ + Parse results once the profiling process has ended. + + Parameters + ---------- + exit_code: int + QProcess exit code. + exit_status: str + QProcess exit status. + """ + self.running = False + self.show_errorlog() # If errors occurred, show them. + self.output = self.error_output + self.output + self.datelabel.setText('') + self.show_data(justanalyzed=True) + self.update_actions() + + def _read_output(self, error=False): + """ + Read otuput from QProcess. + + Parameters + ---------- + error: bool, optional + Process QProcess output or error channels. Default is False. + """ + if error: + self.process.setReadChannel(QProcess.StandardError) + else: + self.process.setReadChannel(QProcess.StandardOutput) + + qba = QByteArray() + while self.process.bytesAvailable(): + if error: + qba += self.process.readAllStandardError() + else: + qba += self.process.readAllStandardOutput() + + text = to_text_string(qba.data(), encoding='utf-8') + if error: + self.error_output += text + else: + self.output += text + + # --- Public API + # ------------------------------------------------------------------------ + def save_data(self): + """Save data.""" + title = _( "Save profiler result") + filename, _selfilter = getsavefilename( + self, + title, + getcwd_or_home(), + _("Profiler result") + " (*.Result)", + ) + + if filename: + self.datatree.save_data(filename) + + def compare(self): + """Compare previous saved run with last run.""" + filename, _selfilter = getopenfilename( + self, + _("Select script to compare"), + getcwd_or_home(), + _("Profiler result") + " (*.Result)", + ) + + if filename: + self.datatree.compare(filename) + self.show_data() + self.clear_action.setEnabled(True) + + def clear(self): + """Clear data in tree.""" + self.datatree.compare(None) + self.datatree.hide_diff_cols(True) + self.show_data() + self.clear_action.setEnabled(False) + + def analyze(self, filename, wdir=None, args=None, pythonpath=None): + """ + Start the profiling process. + + Parameters + ---------- + wdir: str + Working directory path string. Default is None. + args: list + Arguments to pass to the profiling process. Default is None. + pythonpath: str + Python path string. Default is None. + """ + if not is_profiler_installed(): + return + + self._kill_if_running() + + # TODO: storing data is not implemented yet + # index, _data = self.get_data(filename) + combo = self.filecombo + items = [combo.itemText(idx) for idx in range(combo.count())] + index = None + if index is None and filename not in items: + self.filecombo.addItem(filename) + self.filecombo.setCurrentIndex(self.filecombo.count() - 1) + else: + self.filecombo.setCurrentIndex(self.filecombo.findText(filename)) + + self.filecombo.selected() + if self.filecombo.is_valid(): + if wdir is None: + wdir = osp.dirname(filename) + + self.start(wdir, args, pythonpath) + + def select_file(self, filename=None): + """ + Select filename to profile. + + Parameters + ---------- + filename: str, optional + Path to filename to profile. default is None. + + Notes + ----- + If no `filename` is provided an open filename dialog will be used. + """ + if filename is None: + self.sig_redirect_stdio_requested.emit(False) + filename, _selfilter = getopenfilename( + self, + _("Select Python file"), + getcwd_or_home(), + _("Python files") + " (*.py ; *.pyw)" + ) + self.sig_redirect_stdio_requested.emit(True) + + if filename: + self.analyze(filename) + + def show_log(self): + """Show process output log.""" + if self.output: + output_dialog = TextEditor( + self.output, + title=_("Profiler output"), + readonly=True, + parent=self, + ) + output_dialog.resize(700, 500) + output_dialog.exec_() + + def show_errorlog(self): + """Show process error log.""" + if self.error_output: + output_dialog = TextEditor( + self.error_output, + title=_("Profiler output"), + readonly=True, + parent=self, + ) + output_dialog.resize(700, 500) + output_dialog.exec_() + + def start(self, wdir=None, args=None, pythonpath=None): + """ + Start the profiling process. + + Parameters + ---------- + wdir: str + Working directory path string. Default is None. + args: list + Arguments to pass to the profiling process. Default is None. + pythonpath: str + Python path string. Default is None. + """ + filename = to_text_string(self.filecombo.currentText()) + if wdir is None: + wdir = self._last_wdir + if wdir is None: + wdir = osp.basename(filename) + + if args is None: + args = self._last_args + if args is None: + args = [] + + if pythonpath is None: + pythonpath = self._last_pythonpath + + self._last_wdir = wdir + self._last_args = args + self._last_pythonpath = pythonpath + + self.datelabel.setText(_('Profiling, please wait...')) + + self.process = QProcess(self) + self.process.setProcessChannelMode(QProcess.SeparateChannels) + self.process.setWorkingDirectory(wdir) + self.process.readyReadStandardOutput.connect(self._read_output) + self.process.readyReadStandardError.connect( + lambda: self._read_output(error=True)) + self.process.finished.connect( + lambda ec, es=QProcess.ExitStatus: self._finished(ec, es)) + self.process.finished.connect(self.stop_spinner) + + # Start with system environment + proc_env = QProcessEnvironment() + for k, v in os.environ.items(): + proc_env.insert(k, v) + proc_env.insert("PYTHONIOENCODING", "utf8") + proc_env.remove('PYTHONPATH') + if pythonpath is not None: + proc_env.insert('PYTHONPATH', os.pathsep.join(pythonpath)) + self.process.setProcessEnvironment(proc_env) + + executable = self.get_conf('executable', section='main_interpreter') + + if not running_in_mac_app(executable): + env = self.process.processEnvironment() + env.remove('PYTHONHOME') + self.process.setProcessEnvironment(env) + + self.output = '' + self.error_output = '' + self.running = True + self.start_spinner() + + p_args = ['-m', 'cProfile', '-o', self.DATAPATH] + if os.name == 'nt': + # On Windows, one has to replace backslashes by slashes to avoid + # confusion with escape characters (otherwise, for example, '\t' + # will be interpreted as a tabulation): + p_args.append(osp.normpath(filename).replace(os.sep, '/')) + else: + p_args.append(filename) + + if args: + p_args.extend(shell_split(args)) + + self.process.start(executable, p_args) + running = self.process.waitForStarted() + if not running: + QMessageBox.critical( + self, + _("Error"), + _("Process failed to start"), + ) + self.update_actions() + + def stop(self): + """Stop the running process.""" + self.running = False + self.process.close() + self.process.waitForFinished(1000) + self.stop_spinner() + self.update_actions() + + def run(self): + """Toggle starting or running the profiling process.""" + if self.running: + self.stop() + else: + self.start() + + def show_data(self, justanalyzed=False): + """ + Show analyzed data on results tree. + + Parameters + ---------- + justanalyzed: bool, optional + Default is False. + """ + if not justanalyzed: + self.output = None + + self.log_action.setEnabled(self.output is not None + and len(self.output) > 0) + self._kill_if_running() + filename = to_text_string(self.filecombo.currentText()) + if not filename: + return + + self.datelabel.setText(_('Sorting data, please wait...')) + QApplication.processEvents() + + self.datatree.load_data(self.DATAPATH) + self.datatree.show_tree() + + text_style = "%s " + date_text = text_style % (self.text_color, + time.strftime("%Y-%m-%d %H:%M:%S", + time.localtime())) + self.datelabel.setText(date_text) + + +class TreeWidgetItem(QTreeWidgetItem): + def __init__(self, parent=None): + QTreeWidgetItem.__init__(self, parent) + + def __lt__(self, otherItem): + column = self.treeWidget().sortColumn() + try: + if column == 1 or column == 3: # TODO: Hardcoded Column + t0 = gettime_s(self.text(column)) + t1 = gettime_s(otherItem.text(column)) + if t0 is not None and t1 is not None: + return t0 > t1 + + return float(self.text(column)) > float(otherItem.text(column)) + except ValueError: + return self.text(column) > otherItem.text(column) + + +class ProfilerDataTree(QTreeWidget, SpyderWidgetMixin): + """ + Convenience tree widget (with built-in model) + to store and view profiler data. + + The quantities calculated by the profiler are as follows + (from profile.Profile): + [0] = The number of times this function was called, not counting direct + or indirect recursion, + [1] = Number of times this function appears on the stack, minus one + [2] = Total time spent internal to this function + [3] = Cumulative time that this function was present on the stack. In + non-recursive functions, this is the total execution time from start + to finish of each invocation of a function, including time spent in + all subfunctions. + [4] = A dictionary indicating for each function name, the number of times + it was called by us. + """ + SEP = r"<[=]>" # separator between filename and linenumber + # (must be improbable as a filename to avoid splitting the filename itself) + + # Signals + sig_edit_goto_requested = Signal(str, int, str) + + def __init__(self, parent=None): + if PYQT5: + super().__init__(parent, class_parent=parent) + else: + QTreeWidget.__init__(self, parent) + SpyderWidgetMixin.__init__(self, class_parent=parent) + + self.header_list = [_('Function/Module'), _('Total Time'), _('Diff'), + _('Local Time'), _('Diff'), _('Calls'), _('Diff'), + _('File:line')] + self.icon_list = { + 'module': self.create_icon('python'), + 'function': self.create_icon('function'), + 'builtin': self.create_icon('python'), + 'constructor': self.create_icon('class') + } + self.profdata = None # To be filled by self.load_data() + self.stats = None # To be filled by self.load_data() + self.item_depth = None + self.item_list = None + self.items_to_be_shown = None + self.current_view_depth = None + self.compare_file = None + self.setColumnCount(len(self.header_list)) + self.setHeaderLabels(self.header_list) + self.initialize_view() + self.itemActivated.connect(self.item_activated) + self.itemExpanded.connect(self.item_expanded) + + def set_item_data(self, item, filename, line_number): + """Set tree item user data: filename (string) and line_number (int)""" + set_item_user_text(item, '%s%s%d' % (filename, self.SEP, line_number)) + + def get_item_data(self, item): + """Get tree item user data: (filename, line_number)""" + filename, line_number_str = get_item_user_text(item).split(self.SEP) + return filename, int(line_number_str) + + def initialize_view(self): + """Clean the tree and view parameters""" + self.clear() + self.item_depth = 0 # To be use for collapsing/expanding one level + self.item_list = [] # To be use for collapsing/expanding one level + self.items_to_be_shown = {} + self.current_view_depth = 0 + + def load_data(self, profdatafile): + """Load profiler data saved by profile/cProfile module""" + import pstats + # Fixes spyder-ide/spyder#6220. + try: + stats_indi = [pstats.Stats(profdatafile), ] + except (OSError, IOError): + self.profdata = None + return + self.profdata = stats_indi[0] + + if self.compare_file is not None: + # Fixes spyder-ide/spyder#5587. + try: + stats_indi.append(pstats.Stats(self.compare_file)) + except (OSError, IOError) as e: + QMessageBox.critical( + self, _("Error"), + _("Error when trying to load profiler results. " + "The error was

    " + "{0}").format(e)) + self.compare_file = None + map(lambda x: x.calc_callees(), stats_indi) + self.profdata.calc_callees() + self.stats1 = stats_indi + self.stats = stats_indi[0].stats + + def compare(self, filename): + self.hide_diff_cols(False) + self.compare_file = filename + + def hide_diff_cols(self, hide): + for i in (2, 4, 6): + self.setColumnHidden(i, hide) + + def save_data(self, filename): + """Save profiler data.""" + self.stats1[0].dump_stats(filename) + + def find_root(self): + """Find a function without a caller""" + # Fixes spyder-ide/spyder#8336. + if self.profdata is not None: + self.profdata.sort_stats("cumulative") + else: + return + for func in self.profdata.fcn_list: + if ('~', 0) != func[0:2] and not func[2].startswith( + ''): + # This skips the profiler function at the top of the list + # it does only occur in Python 3 + return func + + def find_callees(self, parent): + """Find all functions called by (parent) function.""" + # FIXME: This implementation is very inneficient, because it + # traverses all the data to find children nodes (callees) + return self.profdata.all_callees[parent] + + def show_tree(self): + """Populate the tree with profiler data and display it.""" + self.initialize_view() # Clear before re-populating + self.setItemsExpandable(True) + self.setSortingEnabled(False) + rootkey = self.find_root() # This root contains profiler overhead + if rootkey is not None: + self.populate_tree(self, self.find_callees(rootkey)) + self.resizeColumnToContents(0) + self.setSortingEnabled(True) + self.sortItems(1, Qt.AscendingOrder) # FIXME: hardcoded index + self.change_view(1) + + def function_info(self, functionKey): + """Returns processed information about the function's name and file.""" + node_type = 'function' + filename, line_number, function_name = functionKey + if function_name == '': + modulePath, moduleName = osp.split(filename) + node_type = 'module' + if moduleName == '__init__.py': + modulePath, moduleName = osp.split(modulePath) + function_name = '<' + moduleName + '>' + if not filename or filename == '~': + file_and_line = '(built-in)' + node_type = 'builtin' + else: + if function_name == '__init__': + node_type = 'constructor' + file_and_line = '%s : %d' % (filename, line_number) + return filename, line_number, function_name, file_and_line, node_type + + @staticmethod + def format_measure(measure): + """Get format and units for data coming from profiler task.""" + # Convert to a positive value. + measure = abs(measure) + + # For number of calls + if isinstance(measure, int): + return to_text_string(measure) + + # For time measurements + if 1.e-9 < measure <= 1.e-6: + measure = u"{0:.2f} ns".format(measure / 1.e-9) + elif 1.e-6 < measure <= 1.e-3: + measure = u"{0:.2f} \u03BCs".format(measure / 1.e-6) + elif 1.e-3 < measure <= 1: + measure = u"{0:.2f} ms".format(measure / 1.e-3) + elif 1 < measure <= 60: + measure = u"{0:.2f} s".format(measure) + elif 60 < measure <= 3600: + m, s = divmod(measure, 3600) + if s > 60: + m, s = divmod(measure, 60) + s = to_text_string(s).split(".")[-1] + measure = u"{0:.0f}.{1:.2s} min".format(m, s) + else: + h, m = divmod(measure, 3600) + if m > 60: + m /= 60 + measure = u"{0:.0f}h:{1:.0f}min".format(h, m) + return measure + + def color_string(self, x): + """Return a string formatted delta for the values in x. + + Args: + x: 2-item list of integers (representing number of calls) or + 2-item list of floats (representing seconds of runtime). + + Returns: + A list with [formatted x[0], [color, formatted delta]], where + color reflects whether x[1] is lower, greater, or the same as + x[0]. + """ + diff_str = "" + color = "black" + + if len(x) == 2 and self.compare_file is not None: + difference = x[0] - x[1] + if difference: + color, sign = ((SpyderPalette.COLOR_SUCCESS_1, '-') + if difference < 0 + else (SpyderPalette.COLOR_ERROR_1, '+')) + diff_str = '{}{}'.format(sign, self.format_measure(difference)) + return [self.format_measure(x[0]), [diff_str, color]] + + def format_output(self, child_key): + """ Formats the data. + + self.stats1 contains a list of one or two pstat.Stats() instances, with + the first being the current run and the second, the saved run, if it + exists. Each Stats instance is a dictionary mapping a function to + 5 data points - cumulative calls, number of calls, total time, + cumulative time, and callers. + + format_output() converts the number of calls, total time, and + cumulative time to a string format for the child_key parameter. + """ + data = [x.stats.get(child_key, [0, 0, 0, 0, {}]) for x in self.stats1] + return (map(self.color_string, islice(zip(*data), 1, 4))) + + def populate_tree(self, parentItem, children_list): + """ + Recursive method to create each item (and associated data) + in the tree. + """ + for child_key in children_list: + self.item_depth += 1 + (filename, line_number, function_name, file_and_line, node_type + ) = self.function_info(child_key) + + ((total_calls, total_calls_dif), (loc_time, loc_time_dif), + (cum_time, cum_time_dif)) = self.format_output(child_key) + + child_item = TreeWidgetItem(parentItem) + self.item_list.append(child_item) + self.set_item_data(child_item, filename, line_number) + + # FIXME: indexes to data should be defined by a dictionary on init + child_item.setToolTip(0, _('Function or module name')) + child_item.setData(0, Qt.DisplayRole, function_name) + child_item.setIcon(0, self.icon_list[node_type]) + + child_item.setToolTip(1, _('Time in function ' + '(including sub-functions)')) + child_item.setData(1, Qt.DisplayRole, cum_time) + child_item.setTextAlignment(1, Qt.AlignRight) + + child_item.setData(2, Qt.DisplayRole, cum_time_dif[0]) + child_item.setForeground(2, QColor(cum_time_dif[1])) + child_item.setTextAlignment(2, Qt.AlignLeft) + + child_item.setToolTip(3, _('Local time in function ' + '(not in sub-functions)')) + + child_item.setData(3, Qt.DisplayRole, loc_time) + child_item.setTextAlignment(3, Qt.AlignRight) + + child_item.setData(4, Qt.DisplayRole, loc_time_dif[0]) + child_item.setForeground(4, QColor(loc_time_dif[1])) + child_item.setTextAlignment(4, Qt.AlignLeft) + + child_item.setToolTip(5, _('Total number of calls ' + '(including recursion)')) + + child_item.setData(5, Qt.DisplayRole, total_calls) + child_item.setTextAlignment(5, Qt.AlignRight) + + child_item.setData(6, Qt.DisplayRole, total_calls_dif[0]) + child_item.setForeground(6, QColor(total_calls_dif[1])) + child_item.setTextAlignment(6, Qt.AlignLeft) + + child_item.setToolTip(7, _('File:line ' + 'where function is defined')) + child_item.setData(7, Qt.DisplayRole, file_and_line) + #child_item.setExpanded(True) + if self.is_recursive(child_item): + child_item.setData(7, Qt.DisplayRole, '(%s)' % _('recursion')) + child_item.setDisabled(True) + else: + callees = self.find_callees(child_key) + if self.item_depth < 3: + self.populate_tree(child_item, callees) + elif callees: + child_item.setChildIndicatorPolicy(child_item.ShowIndicator) + self.items_to_be_shown[id(child_item)] = callees + self.item_depth -= 1 + + def item_activated(self, item): + filename, line_number = self.get_item_data(item) + self.sig_edit_goto_requested.emit(filename, line_number, '') + + def item_expanded(self, item): + if item.childCount() == 0 and id(item) in self.items_to_be_shown: + callees = self.items_to_be_shown[id(item)] + self.populate_tree(item, callees) + + def is_recursive(self, child_item): + """Returns True is a function is a descendant of itself.""" + ancestor = child_item.parent() + # FIXME: indexes to data should be defined by a dictionary on init + while ancestor: + if (child_item.data(0, Qt.DisplayRole + ) == ancestor.data(0, Qt.DisplayRole) and + child_item.data(7, Qt.DisplayRole + ) == ancestor.data(7, Qt.DisplayRole)): + return True + else: + ancestor = ancestor.parent() + return False + + def get_top_level_items(self): + """Iterate over top level items""" + return [self.topLevelItem(_i) + for _i in range(self.topLevelItemCount())] + + def get_items(self, maxlevel): + """Return all items with a level <= `maxlevel`""" + itemlist = [] + + def add_to_itemlist(item, maxlevel, level=1): + level += 1 + for index in range(item.childCount()): + citem = item.child(index) + itemlist.append(citem) + if level <= maxlevel: + add_to_itemlist(citem, maxlevel, level) + + for tlitem in self.get_top_level_items(): + itemlist.append(tlitem) + if maxlevel > 0: + add_to_itemlist(tlitem, maxlevel=maxlevel) + return itemlist + + def change_view(self, change_in_depth): + """Change view depth by expanding or collapsing all same-level nodes""" + self.current_view_depth += change_in_depth + if self.current_view_depth < 0: + self.current_view_depth = 0 + self.collapseAll() + if self.current_view_depth > 0: + for item in self.get_items(maxlevel=self.current_view_depth - 1): + item.setExpanded(True) + + +# ============================================================================= +# Tests +# ============================================================================= +def primes(n): + """ + Simple test function + Taken from http://www.huyng.com/posts/python-performance-analysis/ + """ + if n == 2: + return [2] + elif n < 2: + return [] + s = list(range(3, n + 1, 2)) + mroot = n ** 0.5 + half = (n + 1) // 2 - 1 + i = 0 + m = 3 + while m <= mroot: + if s[i]: + j = (m * m - 3) // 2 + s[j] = 0 + while j < half: + s[j] = 0 + j += m + i = i + 1 + m = 2 * i + 3 + return [2] + [x for x in s if x] + + +def test(): + """Run widget test""" + from spyder.utils.qthelpers import qapplication + import inspect + import tempfile + from unittest.mock import MagicMock + + primes_sc = inspect.getsource(primes) + fd, script = tempfile.mkstemp(suffix='.py') + with os.fdopen(fd, 'w') as f: + f.write("# -*- coding: utf-8 -*-" + "\n\n") + f.write(primes_sc + "\n\n") + f.write("primes(100000)") + + plugin_mock = MagicMock() + plugin_mock.CONF_SECTION = 'profiler' + + app = qapplication(test_time=5) + widget = ProfilerWidget('test', plugin=plugin_mock) + widget._setup() + widget.setup() + widget.get_conf('executable', get_python_executable(), + section='main_interpreter') + widget.resize(800, 600) + widget.show() + widget.analyze(script) + sys.exit(app.exec_()) + + +if __name__ == '__main__': + test() diff --git a/spyder/plugins/projects/api.py b/spyder/plugins/projects/api.py index 6c9df2cc59c..e5eae435502 100644 --- a/spyder/plugins/projects/api.py +++ b/spyder/plugins/projects/api.py @@ -1,180 +1,180 @@ -# -*- coding: utf-8 -*- -# -# Copyright © Spyder Project Contributors -# Licensed under the terms of the MIT License -# (see spyder/__init__.py for details) - -""" -Projects Plugin API. -""" - -# Standard library imports -import os.path as osp -from collections import OrderedDict - -# Local imports -from spyder.api.translations import get_translation -from spyder.config.base import get_project_config_folder -from spyder.plugins.projects.utils.config import (ProjectMultiConfig, - PROJECT_NAME_MAP, - PROJECT_DEFAULTS, - PROJECT_CONF_VERSION, - WORKSPACE) - -# Localization -_ = get_translation("spyder") - - -class BaseProjectType: - """ - Spyder base project. - - This base class must not be used directly, but inherited from. It does not - assume that python is specific to this project. - """ - ID = None - - def __init__(self, root_path, parent_plugin=None): - self.plugin = parent_plugin - self.root_path = root_path - self.open_project_files = [] - self.open_non_project_files = [] - path = osp.join(root_path, get_project_config_folder(), 'config') - self.config = ProjectMultiConfig( - PROJECT_NAME_MAP, - path=path, - defaults=PROJECT_DEFAULTS, - load=True, - version=PROJECT_CONF_VERSION, - backup=True, - raw_mode=True, - remove_obsolete=False, - ) - act_name = self.get_option("project_type") - if not act_name: - self.set_option("project_type", self.ID) - - # --- Helpers - # ------------------------------------------------------------------------- - def get_option(self, option, section=WORKSPACE, default=None): - """Get project configuration option.""" - return self.config.get(section=section, option=option, default=default) - - def set_option(self, option, value, section=WORKSPACE): - """Set project configuration option.""" - self.config.set(section=section, option=option, value=value) - - def set_recent_files(self, recent_files): - """Set a list of files opened by the project.""" - processed_recent_files = [] - for recent_file in recent_files: - if osp.isfile(recent_file): - try: - relative_recent_file = osp.relpath( - recent_file, self.root_path) - processed_recent_files.append(relative_recent_file) - except ValueError: - processed_recent_files.append(recent_file) - - files = list(OrderedDict.fromkeys(processed_recent_files)) - self.set_option("recent_files", files) - - def get_recent_files(self): - """Return a list of files opened by the project.""" - - # Check if recent_files in [main] (Spyder 4) - recent_files = self.get_option("recent_files", 'main', []) - if recent_files: - # Move to [workspace] (Spyder 5) - self.config.remove_option('main', 'recent_files') - self.set_recent_files(recent_files) - else: - recent_files = self.get_option("recent_files", default=[]) - - recent_files = [recent_file if osp.isabs(recent_file) - else osp.join(self.root_path, recent_file) - for recent_file in recent_files] - for recent_file in recent_files[:]: - if not osp.isfile(recent_file): - recent_files.remove(recent_file) - - return list(OrderedDict.fromkeys(recent_files)) - - # --- API - # ------------------------------------------------------------------------ - @staticmethod - def get_name(): - """ - Provide a human readable version of NAME. - """ - raise NotImplementedError("Must implement a `get_name` method!") - - @staticmethod - def validate_name(path, name): - """ - Validate the project's name. - - Returns - ------- - tuple - The first item (bool) indicates if the name was validated - successfully, and the second item (str) indicates the error - message, if any. - """ - return True, "" - - def create_project(self): - """ - Create a project and do any additional setup for this project type. - - Returns - ------- - tuple - The first item (bool) indicates if the project was created - successfully, and the second item (str) indicates the error - message, if any. - """ - return False, "A ProjectType must define a `create_project` method!" - - def open_project(self): - """ - Open a project and do any additional setup for this project type. - - Returns - ------- - tuple - The first item (bool) indicates if the project was opened - successfully, and the second item (str) indicates the error - message, if any. - """ - return False, "A ProjectType must define an `open_project` method!" - - def close_project(self): - """ - Close a project and do any additional setup for this project type. - - Returns - ------- - tuple - The first item (bool) indicates if the project was closed - successfully, and the second item (str) indicates the error - message, if any. - """ - return False, "A ProjectType must define a `close_project` method!" - - -class EmptyProject(BaseProjectType): - ID = 'empty-project-type' - - @staticmethod - def get_name(): - return _("Empty project") - - def create_project(self): - return True, "" - - def open_project(self): - return True, "" - - def close_project(self): - return True, "" +# -*- coding: utf-8 -*- +# +# Copyright © Spyder Project Contributors +# Licensed under the terms of the MIT License +# (see spyder/__init__.py for details) + +""" +Projects Plugin API. +""" + +# Standard library imports +import os.path as osp +from collections import OrderedDict + +# Local imports +from spyder.api.translations import get_translation +from spyder.config.base import get_project_config_folder +from spyder.plugins.projects.utils.config import (ProjectMultiConfig, + PROJECT_NAME_MAP, + PROJECT_DEFAULTS, + PROJECT_CONF_VERSION, + WORKSPACE) + +# Localization +_ = get_translation("spyder") + + +class BaseProjectType: + """ + Spyder base project. + + This base class must not be used directly, but inherited from. It does not + assume that python is specific to this project. + """ + ID = None + + def __init__(self, root_path, parent_plugin=None): + self.plugin = parent_plugin + self.root_path = root_path + self.open_project_files = [] + self.open_non_project_files = [] + path = osp.join(root_path, get_project_config_folder(), 'config') + self.config = ProjectMultiConfig( + PROJECT_NAME_MAP, + path=path, + defaults=PROJECT_DEFAULTS, + load=True, + version=PROJECT_CONF_VERSION, + backup=True, + raw_mode=True, + remove_obsolete=False, + ) + act_name = self.get_option("project_type") + if not act_name: + self.set_option("project_type", self.ID) + + # --- Helpers + # ------------------------------------------------------------------------- + def get_option(self, option, section=WORKSPACE, default=None): + """Get project configuration option.""" + return self.config.get(section=section, option=option, default=default) + + def set_option(self, option, value, section=WORKSPACE): + """Set project configuration option.""" + self.config.set(section=section, option=option, value=value) + + def set_recent_files(self, recent_files): + """Set a list of files opened by the project.""" + processed_recent_files = [] + for recent_file in recent_files: + if osp.isfile(recent_file): + try: + relative_recent_file = osp.relpath( + recent_file, self.root_path) + processed_recent_files.append(relative_recent_file) + except ValueError: + processed_recent_files.append(recent_file) + + files = list(OrderedDict.fromkeys(processed_recent_files)) + self.set_option("recent_files", files) + + def get_recent_files(self): + """Return a list of files opened by the project.""" + + # Check if recent_files in [main] (Spyder 4) + recent_files = self.get_option("recent_files", 'main', []) + if recent_files: + # Move to [workspace] (Spyder 5) + self.config.remove_option('main', 'recent_files') + self.set_recent_files(recent_files) + else: + recent_files = self.get_option("recent_files", default=[]) + + recent_files = [recent_file if osp.isabs(recent_file) + else osp.join(self.root_path, recent_file) + for recent_file in recent_files] + for recent_file in recent_files[:]: + if not osp.isfile(recent_file): + recent_files.remove(recent_file) + + return list(OrderedDict.fromkeys(recent_files)) + + # --- API + # ------------------------------------------------------------------------ + @staticmethod + def get_name(): + """ + Provide a human readable version of NAME. + """ + raise NotImplementedError("Must implement a `get_name` method!") + + @staticmethod + def validate_name(path, name): + """ + Validate the project's name. + + Returns + ------- + tuple + The first item (bool) indicates if the name was validated + successfully, and the second item (str) indicates the error + message, if any. + """ + return True, "" + + def create_project(self): + """ + Create a project and do any additional setup for this project type. + + Returns + ------- + tuple + The first item (bool) indicates if the project was created + successfully, and the second item (str) indicates the error + message, if any. + """ + return False, "A ProjectType must define a `create_project` method!" + + def open_project(self): + """ + Open a project and do any additional setup for this project type. + + Returns + ------- + tuple + The first item (bool) indicates if the project was opened + successfully, and the second item (str) indicates the error + message, if any. + """ + return False, "A ProjectType must define an `open_project` method!" + + def close_project(self): + """ + Close a project and do any additional setup for this project type. + + Returns + ------- + tuple + The first item (bool) indicates if the project was closed + successfully, and the second item (str) indicates the error + message, if any. + """ + return False, "A ProjectType must define a `close_project` method!" + + +class EmptyProject(BaseProjectType): + ID = 'empty-project-type' + + @staticmethod + def get_name(): + return _("Empty project") + + def create_project(self): + return True, "" + + def open_project(self): + return True, "" + + def close_project(self): + return True, "" diff --git a/spyder/plugins/projects/plugin.py b/spyder/plugins/projects/plugin.py index 14fd1a1b3de..90fac1ef201 100644 --- a/spyder/plugins/projects/plugin.py +++ b/spyder/plugins/projects/plugin.py @@ -1,1022 +1,1022 @@ -# -*- coding: utf-8 -*- -# -# Copyright © Spyder Project Contributors -# Licensed under the terms of the MIT License -# (see spyder/__init__.py for details) - -""" -Projects Plugin - -It handles closing, opening and switching among projetcs and also -updating the file tree explorer associated with a project -""" - -# Standard library imports -import configparser -import logging -import os -import os.path as osp -import shutil -from collections import OrderedDict - -# Third party imports -from qtpy.compat import getexistingdirectory -from qtpy.QtCore import Signal, Slot -from qtpy.QtWidgets import QInputDialog, QMessageBox - -# Local imports -from spyder.api.exceptions import SpyderAPIError -from spyder.api.plugin_registration.decorators import ( - on_plugin_available, on_plugin_teardown) -from spyder.api.translations import get_translation -from spyder.api.plugins import Plugins, SpyderDockablePlugin -from spyder.config.base import (get_home_dir, get_project_config_folder, - running_in_mac_app, running_under_pytest) -from spyder.py3compat import is_text_string, to_text_string -from spyder.utils import encoding -from spyder.utils.icon_manager import ima -from spyder.utils.misc import getcwd_or_home -from spyder.plugins.mainmenu.api import ApplicationMenus, ProjectsMenuSections -from spyder.plugins.projects.api import (BaseProjectType, EmptyProject, - WORKSPACE) -from spyder.plugins.projects.utils.watcher import WorkspaceWatcher -from spyder.plugins.projects.widgets.main_widget import ProjectExplorerWidget -from spyder.plugins.projects.widgets.projectdialog import ProjectDialog -from spyder.plugins.completion.api import ( - CompletionRequestTypes, FileChangeType, WorkspaceUpdateKind) -from spyder.plugins.completion.decorators import ( - request, handles, class_register) - -# Localization and logging -_ = get_translation("spyder") -logger = logging.getLogger(__name__) - - -class ProjectsMenuSubmenus: - RecentProjects = 'recent_projects' - - -class ProjectsActions: - NewProject = 'new_project_action' - OpenProject = 'open_project_action' - CloseProject = 'close_project_action' - DeleteProject = 'delete_project_action' - ClearRecentProjects = 'clear_recent_projects_action' - MaxRecent = 'max_recent_action' - - -class RecentProjectsMenuSections: - Recent = 'recent_section' - Extras = 'extras_section' - - -@class_register -class Projects(SpyderDockablePlugin): - """Projects plugin.""" - NAME = 'project_explorer' - CONF_SECTION = NAME - CONF_FILE = False - REQUIRES = [] - OPTIONAL = [Plugins.Completions, Plugins.IPythonConsole, Plugins.Editor, - Plugins.MainMenu] - WIDGET_CLASS = ProjectExplorerWidget - - # Signals - sig_project_created = Signal(str, str, object) - """ - This signal is emitted to request the Projects plugin the creation of a - project. - - Parameters - ---------- - project_path: str - Location of project. - project_type: str - Type of project as defined by project types. - project_packages: object - Package to install. Currently not in use. - """ - - sig_project_loaded = Signal(object) - """ - This signal is emitted when a project is loaded. - - Parameters - ---------- - project_path: object - Loaded project path. - """ - - sig_project_closed = Signal((object,), (bool,)) - """ - This signal is emitted when a project is closed. - - Parameters - ---------- - project_path: object - Closed project path (signature 1). - close_project: bool - This is emitted only when closing a project but not when switching - between projects (signature 2). - """ - - sig_pythonpath_changed = Signal() - """ - This signal is emitted when the Python path has changed. - """ - - def __init__(self, parent=None, configuration=None): - """Initialization.""" - super().__init__(parent, configuration) - self.recent_projects = self.get_conf('recent_projects', []) - self.current_active_project = None - self.latest_project = None - self.watcher = WorkspaceWatcher(self) - self.completions_available = False - self.get_widget().setup_project(self.get_active_project_path()) - self.watcher.connect_signals(self) - self._project_types = OrderedDict() - - # ---- SpyderDockablePlugin API - # ------------------------------------------------------------------------ - @staticmethod - def get_name(): - return _("Projects") - - def get_description(self): - return _("Create Spyder projects and manage their files.") - - def get_icon(self): - return self.create_icon('project') - - def on_initialize(self): - """Register plugin in Spyder's main window""" - widget = self.get_widget() - treewidget = widget.treewidget - - self.ipyconsole = None - self.editor = None - self.completions = None - - treewidget.sig_delete_project.connect(self.delete_project) - treewidget.sig_redirect_stdio_requested.connect( - self.sig_redirect_stdio_requested) - self.sig_switch_to_plugin_requested.connect( - lambda plugin, check: self.show_explorer()) - self.sig_project_loaded.connect(self.update_explorer) - - if self.main: - widget.sig_open_file_requested.connect(self.main.open_file) - self.main.project_path = self.get_pythonpath(at_start=True) - self.sig_project_loaded.connect( - lambda v: self.main.set_window_title()) - self.sig_project_closed.connect( - lambda v: self.main.set_window_title()) - self.main.restore_scrollbar_position.connect( - self.restore_scrollbar_position) - self.sig_pythonpath_changed.connect(self.main.pythonpath_changed) - - self.register_project_type(self, EmptyProject) - self.setup() - - @on_plugin_available(plugin=Plugins.Editor) - def on_editor_available(self): - self.editor = self.get_plugin(Plugins.Editor) - widget = self.get_widget() - treewidget = widget.treewidget - - treewidget.sig_open_file_requested.connect(self.editor.load) - treewidget.sig_removed.connect(self.editor.removed) - treewidget.sig_tree_removed.connect(self.editor.removed_tree) - treewidget.sig_renamed.connect(self.editor.renamed) - treewidget.sig_tree_renamed.connect(self.editor.renamed_tree) - treewidget.sig_module_created.connect(self.editor.new) - treewidget.sig_file_created.connect(self._new_editor) - - self.sig_project_loaded.connect(self._setup_editor_files) - self.sig_project_closed[bool].connect(self._setup_editor_files) - - self.editor.set_projects(self) - self.sig_project_loaded.connect(self._set_path_in_editor) - self.sig_project_closed.connect(self._unset_path_in_editor) - - @on_plugin_available(plugin=Plugins.Completions) - def on_completions_available(self): - self.completions = self.get_plugin(Plugins.Completions) - - # TODO: This is not necessary anymore due to us starting workspace - # services in the editor. However, we could restore it in the future. - # completions.sig_language_completions_available.connect( - # lambda settings, language: - # self.start_workspace_services()) - self.completions.sig_stop_completions.connect( - self.stop_workspace_services) - self.sig_project_loaded.connect(self._add_path_to_completions) - self.sig_project_closed.connect(self._remove_path_from_completions) - - @on_plugin_available(plugin=Plugins.IPythonConsole) - def on_ipython_console_available(self): - self.ipyconsole = self.get_plugin(Plugins.IPythonConsole) - widget = self.get_widget() - treewidget = widget.treewidget - treewidget.sig_open_interpreter_requested.connect( - self.ipyconsole.create_client_from_path) - treewidget.sig_run_requested.connect(self._run_file_in_ipyconsole) - - @on_plugin_available(plugin=Plugins.MainMenu) - def on_main_menu_available(self): - main_menu = self.get_plugin(Plugins.MainMenu) - new_project_action = self.get_action(ProjectsActions.NewProject) - open_project_action = self.get_action(ProjectsActions.OpenProject) - - projects_menu = main_menu.get_application_menu( - ApplicationMenus.Projects) - projects_menu.aboutToShow.connect(self.is_invalid_active_project) - - main_menu.add_item_to_application_menu( - new_project_action, - menu_id=ApplicationMenus.Projects, - section=ProjectsMenuSections.New) - - for item in [open_project_action, self.close_project_action, - self.delete_project_action]: - main_menu.add_item_to_application_menu( - item, - menu_id=ApplicationMenus.Projects, - section=ProjectsMenuSections.Open) - - main_menu.add_item_to_application_menu( - self.recent_project_menu, - menu_id=ApplicationMenus.Projects, - section=ProjectsMenuSections.Extras) - - @on_plugin_teardown(plugin=Plugins.Editor) - def on_editor_teardown(self): - self.editor = self.get_plugin(Plugins.Editor) - widget = self.get_widget() - treewidget = widget.treewidget - - treewidget.sig_open_file_requested.disconnect(self.editor.load) - treewidget.sig_removed.disconnect(self.editor.removed) - treewidget.sig_tree_removed.disconnect(self.editor.removed_tree) - treewidget.sig_renamed.disconnect(self.editor.renamed) - treewidget.sig_tree_renamed.disconnect(self.editor.renamed_tree) - treewidget.sig_module_created.disconnect(self.editor.new) - treewidget.sig_file_created.disconnect(self._new_editor) - - self.sig_project_loaded.disconnect(self._setup_editor_files) - self.sig_project_closed[bool].disconnect(self._setup_editor_files) - self.editor.set_projects(None) - self.sig_project_loaded.disconnect(self._set_path_in_editor) - self.sig_project_closed.disconnect(self._unset_path_in_editor) - - self.editor = None - - @on_plugin_teardown(plugin=Plugins.Completions) - def on_completions_teardown(self): - self.completions = self.get_plugin(Plugins.Completions) - - self.completions.sig_stop_completions.disconnect( - self.stop_workspace_services) - - self.sig_project_loaded.disconnect(self._add_path_to_completions) - self.sig_project_closed.disconnect(self._remove_path_from_completions) - - self.completions = None - - @on_plugin_teardown(plugin=Plugins.IPythonConsole) - def on_ipython_console_teardown(self): - self.ipyconsole = self.get_plugin(Plugins.IPythonConsole) - widget = self.get_widget() - treewidget = widget.treewidget - - treewidget.sig_open_interpreter_requested.disconnect( - self.ipyconsole.create_client_from_path) - treewidget.sig_run_requested.disconnect(self._run_file_in_ipyconsole) - - self._ipython_run_script = None - self.ipyconsole = None - - @on_plugin_teardown(plugin=Plugins.MainMenu) - def on_main_menu_teardown(self): - main_menu = self.get_plugin(Plugins.MainMenu) - main_menu.remove_application_menu(ApplicationMenus.Projects) - - def setup(self): - """Setup the plugin actions.""" - self.create_action( - ProjectsActions.NewProject, - text=_("New Project..."), - triggered=self.create_new_project) - - self.create_action( - ProjectsActions.OpenProject, - text=_("Open Project..."), - triggered=lambda v: self.open_project()) - - self.close_project_action = self.create_action( - ProjectsActions.CloseProject, - text=_("Close Project"), - triggered=self.close_project) - - self.delete_project_action = self.create_action( - ProjectsActions.DeleteProject, - text=_("Delete Project"), - triggered=self.delete_project) - - self.clear_recent_projects_action = self.create_action( - ProjectsActions.ClearRecentProjects, - text=_("Clear this list"), - triggered=self.clear_recent_projects) - - self.max_recent_action = self.create_action( - ProjectsActions.MaxRecent, - text=_("Maximum number of recent projects..."), - triggered=self.change_max_recent_projects) - - self.recent_project_menu = self.get_widget().create_menu( - ProjectsMenuSubmenus.RecentProjects, - _("Recent Projects") - ) - self.recent_project_menu.aboutToShow.connect(self.setup_menu_actions) - self.setup_menu_actions() - - def setup_menu_actions(self): - """Setup and update the menu actions.""" - if self.recent_projects: - for project in self.recent_projects: - if self.is_valid_project(project): - if os.name == 'nt': - name = project - else: - name = project.replace(get_home_dir(), '~') - try: - action = self.get_action(name) - except KeyError: - action = self.create_action( - name, - text=name, - icon=ima.icon('project'), - triggered=self.build_opener(project), - ) - self.get_widget().add_item_to_menu( - action, - menu=self.recent_project_menu, - section=RecentProjectsMenuSections.Recent) - - for item in [self.clear_recent_projects_action, - self.max_recent_action]: - self.get_widget().add_item_to_menu( - item, - menu=self.recent_project_menu, - section=RecentProjectsMenuSections.Extras) - self.update_project_actions() - - def update_project_actions(self): - """Update actions of the Projects menu""" - if self.recent_projects: - self.clear_recent_projects_action.setEnabled(True) - else: - self.clear_recent_projects_action.setEnabled(False) - - active = bool(self.get_active_project_path()) - self.close_project_action.setEnabled(active) - self.delete_project_action.setEnabled(active) - - def on_close(self, cancelable=False): - """Perform actions before parent main window is closed""" - self.save_config() - self.watcher.stop() - return True - - def unmaximize(self): - """Unmaximize the currently maximized plugin, if not self.""" - if self.main: - if (self.main.last_plugin is not None and - self.main.last_plugin._ismaximized and - self.main.last_plugin is not self): - self.main.maximize_dockwidget() - - def build_opener(self, project): - """Build function opening passed project""" - def opener(*args, **kwargs): - self.open_project(path=project) - return opener - - def on_mainwindow_visible(self): - # Open project passed on the command line or reopen last one. - cli_options = self.get_command_line_options() - initial_cwd = self._main.get_initial_working_directory() - - if cli_options.project is not None: - # This doesn't work for our Mac app - if not running_in_mac_app(): - logger.debug('Opening project from the command line') - project = osp.normpath( - osp.join(initial_cwd, cli_options.project) - ) - self.open_project( - project, - workdir=cli_options.working_directory - ) - else: - logger.debug('Reopening project from last session') - self.reopen_last_project() - - # ------ Public API ------------------------------------------------------- - @Slot() - def create_new_project(self): - """Create new project.""" - self.unmaximize() - dlg = ProjectDialog(self.get_widget(), - project_types=self.get_project_types()) - result = dlg.exec_() - data = dlg.project_data - root_path = data.get("root_path", None) - project_type = data.get("project_type", EmptyProject.ID) - - if result: - self._create_project(root_path, project_type_id=project_type) - dlg.close() - - def _create_project(self, root_path, project_type_id=EmptyProject.ID, - packages=None): - """Create a new project.""" - project_types = self.get_project_types() - if project_type_id in project_types: - project_type_class = project_types[project_type_id] - project = project_type_class( - root_path=root_path, - parent_plugin=project_type_class._PARENT_PLUGIN, - ) - - created_succesfully, message = project.create_project() - if not created_succesfully: - QMessageBox.warning( - self.get_widget(), "Project creation", message) - shutil.rmtree(root_path, ignore_errors=True) - return - - # TODO: In a subsequent PR return a value and emit based on that - self.sig_project_created.emit(root_path, project_type_id, packages) - self.open_project(path=root_path, project=project) - else: - if not running_under_pytest(): - QMessageBox.critical( - self.get_widget(), - _('Error'), - _("{} is not a registered Spyder project " - "type!").format(project_type_id) - ) - - def open_project(self, path=None, project=None, restart_consoles=True, - save_previous_files=True, workdir=None): - """Open the project located in `path`.""" - self.unmaximize() - if path is None: - basedir = get_home_dir() - path = getexistingdirectory(parent=self.get_widget(), - caption=_("Open project"), - basedir=basedir) - path = encoding.to_unicode_from_fs(path) - if not self.is_valid_project(path): - if path: - QMessageBox.critical( - self.get_widget(), - _('Error'), - _("%s is not a Spyder project!") % path, - ) - return - else: - path = encoding.to_unicode_from_fs(path) - - logger.debug(f'Opening project located at {path}') - - if project is None: - project_type_class = self._load_project_type_class(path) - project = project_type_class( - root_path=path, - parent_plugin=project_type_class._PARENT_PLUGIN, - ) - - # A project was not open before - if self.current_active_project is None: - if save_previous_files and self.editor is not None: - self.editor.save_open_files() - - if self.editor is not None: - self.set_conf('last_working_dir', getcwd_or_home(), - section='editor') - - if self.get_conf('visible_if_project_open'): - self.show_explorer() - else: - # We are switching projects - if self.editor is not None: - self.set_project_filenames(self.editor.get_open_filenames()) - - # TODO: Don't emit sig_project_closed when we support - # multiple workspaces. - self.sig_project_closed.emit( - self.current_active_project.root_path) - self.watcher.stop() - - self.current_active_project = project - self.latest_project = project - self.add_to_recent(path) - - self.set_conf('current_project_path', self.get_active_project_path()) - - self.setup_menu_actions() - if workdir and osp.isdir(workdir): - self.sig_project_loaded.emit(workdir) - else: - self.sig_project_loaded.emit(path) - self.sig_pythonpath_changed.emit() - self.watcher.start(path) - - if restart_consoles: - self.restart_consoles() - - open_successfully, message = project.open_project() - if not open_successfully: - QMessageBox.warning(self.get_widget(), "Project open", message) - - def close_project(self): - """ - Close current project and return to a window without an active - project - """ - if self.current_active_project: - self.unmaximize() - if self.editor is not None: - self.set_project_filenames( - self.editor.get_open_filenames()) - path = self.current_active_project.root_path - closed_sucessfully, message = ( - self.current_active_project.close_project()) - if not closed_sucessfully: - QMessageBox.warning( - self.get_widget(), "Project close", message) - - self.current_active_project = None - self.set_conf('current_project_path', None) - self.setup_menu_actions() - - self.sig_project_closed.emit(path) - self.sig_project_closed[bool].emit(True) - self.sig_pythonpath_changed.emit() - - # Hide pane. - self.set_conf('visible_if_project_open', - self.get_widget().isVisible()) - self.toggle_view(False) - - self.get_widget().clear() - self.restart_consoles() - self.watcher.stop() - - def delete_project(self): - """ - Delete the current project without deleting the files in the directory. - """ - if self.current_active_project: - self.unmaximize() - path = self.current_active_project.root_path - buttons = QMessageBox.Yes | QMessageBox.No - answer = QMessageBox.warning( - self.get_widget(), - _("Delete"), - _("Do you really want to delete {filename}?

    " - "Note: This action will only delete the project. " - "Its files are going to be preserved on disk." - ).format(filename=osp.basename(path)), - buttons) - if answer == QMessageBox.Yes: - try: - self.close_project() - shutil.rmtree(osp.join(path, '.spyproject')) - except EnvironmentError as error: - QMessageBox.critical( - self.get_widget(), - _("Project Explorer"), - _("Unable to delete {varpath}" - "

    The error message was:
    {error}" - ).format(varpath=path, error=to_text_string(error))) - - def clear_recent_projects(self): - """Clear the list of recent projects""" - self.recent_projects = [] - self.set_conf('recent_projects', self.recent_projects) - self.setup_menu_actions() - - def change_max_recent_projects(self): - """Change max recent projects entries.""" - - mrf, valid = QInputDialog.getInt( - self.get_widget(), - _('Projects'), - _('Maximum number of recent projects'), - self.get_conf('max_recent_projects'), - 1, - 35) - - if valid: - self.set_conf('max_recent_projects', mrf) - - def get_active_project(self): - """Get the active project""" - return self.current_active_project - - def reopen_last_project(self): - """ - Reopen the active project when Spyder was closed last time, if any - """ - current_project_path = self.get_conf('current_project_path', - default=None) - - # Needs a safer test of project existence! - if ( - current_project_path and - self.is_valid_project(current_project_path) - ): - cli_options = self.get_command_line_options() - self.open_project( - path=current_project_path, - restart_consoles=True, - save_previous_files=False, - workdir=cli_options.working_directory - ) - self.load_config() - - def get_project_filenames(self): - """Get the list of recent filenames of a project""" - recent_files = [] - if self.current_active_project: - recent_files = self.current_active_project.get_recent_files() - elif self.latest_project: - recent_files = self.latest_project.get_recent_files() - return recent_files - - def set_project_filenames(self, recent_files): - """Set the list of open file names in a project""" - if (self.current_active_project - and self.is_valid_project( - self.current_active_project.root_path)): - self.current_active_project.set_recent_files(recent_files) - - def get_active_project_path(self): - """Get path of the active project""" - active_project_path = None - if self.current_active_project: - active_project_path = self.current_active_project.root_path - return active_project_path - - def get_pythonpath(self, at_start=False): - """Get project path as a list to be added to PYTHONPATH""" - if at_start: - current_path = self.get_conf('current_project_path', - default=None) - else: - current_path = self.get_active_project_path() - if current_path is None: - return [] - else: - return [current_path] - - def get_last_working_dir(self): - """Get the path of the last working directory""" - return self.get_conf( - 'last_working_dir', section='editor', default=getcwd_or_home()) - - def save_config(self): - """ - Save configuration: opened projects & tree widget state. - - Also save whether dock widget is visible if a project is open. - """ - self.set_conf('recent_projects', self.recent_projects) - self.set_conf('expanded_state', - self.get_widget().treewidget.get_expanded_state()) - self.set_conf('scrollbar_position', - self.get_widget().treewidget.get_scrollbar_position()) - if self.current_active_project: - self.set_conf('visible_if_project_open', - self.get_widget().isVisible()) - - def load_config(self): - """Load configuration: opened projects & tree widget state""" - expanded_state = self.get_conf('expanded_state', None) - # Sometimes the expanded state option may be truncated in .ini file - # (for an unknown reason), in this case it would be converted to a - # string by 'userconfig': - if is_text_string(expanded_state): - expanded_state = None - if expanded_state is not None: - self.get_widget().treewidget.set_expanded_state(expanded_state) - - def restore_scrollbar_position(self): - """Restoring scrollbar position after main window is visible""" - scrollbar_pos = self.get_conf('scrollbar_position', None) - if scrollbar_pos is not None: - self.get_widget().treewidget.set_scrollbar_position(scrollbar_pos) - - def update_explorer(self): - """Update explorer tree""" - self.get_widget().setup_project(self.get_active_project_path()) - - def show_explorer(self): - """Show the explorer""" - if self.get_widget() is not None: - self.toggle_view(True) - self.get_widget().setVisible(True) - self.get_widget().raise_() - self.get_widget().update() - - def restart_consoles(self): - """Restart consoles when closing, opening and switching projects""" - if self.ipyconsole is not None: - self.ipyconsole.restart() - - def is_valid_project(self, path): - """Check if a directory is a valid Spyder project""" - spy_project_dir = osp.join(path, '.spyproject') - return osp.isdir(path) and osp.isdir(spy_project_dir) - - def is_invalid_active_project(self): - """Handle an invalid active project.""" - try: - path = self.get_active_project_path() - except AttributeError: - return - - if bool(path): - if not self.is_valid_project(path): - if path: - QMessageBox.critical( - self.get_widget(), - _('Error'), - _("{} is no longer a valid Spyder project! " - "Since it is the current active project, it will " - "be closed automatically.").format(path) - ) - self.close_project() - - def add_to_recent(self, project): - """ - Add an entry to recent projetcs - - We only maintain the list of the 10 most recent projects - """ - if project not in self.recent_projects: - self.recent_projects.insert(0, project) - if len(self.recent_projects) > self.get_conf('max_recent_projects'): - self.recent_projects.pop(-1) - - def start_workspace_services(self): - """Enable LSP workspace functionality.""" - self.completions_available = True - if self.current_active_project: - path = self.get_active_project_path() - self.notify_project_open(path) - - def stop_workspace_services(self, _language): - """Disable LSP workspace functionality.""" - self.completions_available = False - - def emit_request(self, method, params, requires_response): - """Send request/notification/response to all LSP servers.""" - params['requires_response'] = requires_response - params['response_instance'] = self - if self.completions: - self.completions.broadcast_notification(method, params) - - @Slot(str, dict) - def handle_response(self, method, params): - """Method dispatcher for LSP requests.""" - if method in self.handler_registry: - handler_name = self.handler_registry[method] - handler = getattr(self, handler_name) - handler(params) - - @Slot(str, str, bool) - @request(method=CompletionRequestTypes.WORKSPACE_WATCHED_FILES_UPDATE, - requires_response=False) - def file_moved(self, src_file, dest_file, is_dir): - """Notify LSP server about a file that is moved.""" - # LSP specification only considers file updates - if is_dir: - return - - deletion_entry = { - 'file': src_file, - 'kind': FileChangeType.DELETED - } - - addition_entry = { - 'file': dest_file, - 'kind': FileChangeType.CREATED - } - - entries = [addition_entry, deletion_entry] - params = { - 'params': entries - } - return params - - @request(method=CompletionRequestTypes.WORKSPACE_WATCHED_FILES_UPDATE, - requires_response=False) - @Slot(str, bool) - def file_created(self, src_file, is_dir): - """Notify LSP server about file creation.""" - if is_dir: - return - - params = { - 'params': [{ - 'file': src_file, - 'kind': FileChangeType.CREATED - }] - } - return params - - @request(method=CompletionRequestTypes.WORKSPACE_WATCHED_FILES_UPDATE, - requires_response=False) - @Slot(str, bool) - def file_deleted(self, src_file, is_dir): - """Notify LSP server about file deletion.""" - if is_dir: - return - - params = { - 'params': [{ - 'file': src_file, - 'kind': FileChangeType.DELETED - }] - } - return params - - @request(method=CompletionRequestTypes.WORKSPACE_WATCHED_FILES_UPDATE, - requires_response=False) - @Slot(str, bool) - def file_modified(self, src_file, is_dir): - """Notify LSP server about file modification.""" - if is_dir: - return - - params = { - 'params': [{ - 'file': src_file, - 'kind': FileChangeType.CHANGED - }] - } - return params - - @request(method=CompletionRequestTypes.WORKSPACE_FOLDERS_CHANGE, - requires_response=False) - def notify_project_open(self, path): - """Notify LSP server about project path availability.""" - params = { - 'folder': path, - 'instance': self, - 'kind': 'addition' - } - return params - - @request(method=CompletionRequestTypes.WORKSPACE_FOLDERS_CHANGE, - requires_response=False) - def notify_project_close(self, path): - """Notify LSP server to unregister project path.""" - params = { - 'folder': path, - 'instance': self, - 'kind': 'deletion' - } - return params - - @handles(CompletionRequestTypes.WORKSPACE_APPLY_EDIT) - @request(method=CompletionRequestTypes.WORKSPACE_APPLY_EDIT, - requires_response=False) - def handle_workspace_edit(self, params): - """Apply edits to multiple files and notify server about success.""" - edits = params['params'] - response = { - 'applied': False, - 'error': 'Not implemented', - 'language': edits['language'] - } - return response - - # --- New API: - # ------------------------------------------------------------------------ - def _load_project_type_class(self, path): - """ - Load a project type class from the config project folder directly. - - Notes - ----- - This is done directly, since using the EmptyProject would rewrite the - value in the constructor. If the project found has not been registered - as a valid project type, the EmptyProject type will be returned. - - Returns - ------- - spyder.plugins.projects.api.BaseProjectType - Loaded project type class. - """ - fpath = osp.join( - path, get_project_config_folder(), 'config', WORKSPACE + ".ini") - - project_type_id = EmptyProject.ID - if osp.isfile(fpath): - config = configparser.ConfigParser() - - # Catch any possible error when reading the workspace config file. - # Fixes spyder-ide/spyder#17621 - try: - config.read(fpath, encoding='utf-8') - except Exception: - pass - - # This is necessary to catch an error for projects created in - # Spyder 4 or older versions. - # Fixes spyder-ide/spyder17097 - try: - project_type_id = config[WORKSPACE].get( - "project_type", EmptyProject.ID) - except KeyError: - pass - - EmptyProject._PARENT_PLUGIN = self - project_types = self.get_project_types() - project_type_class = project_types.get(project_type_id, EmptyProject) - return project_type_class - - def register_project_type(self, parent_plugin, project_type): - """ - Register a new project type. - - Parameters - ---------- - parent_plugin: spyder.plugins.api.plugins.SpyderPluginV2 - The parent plugin instance making the project type registration. - project_type: spyder.plugins.projects.api.BaseProjectType - Project type to register. - """ - if not issubclass(project_type, BaseProjectType): - raise SpyderAPIError("A project type must subclass " - "BaseProjectType!") - - project_id = project_type.ID - if project_id in self._project_types: - raise SpyderAPIError("A project type id '{}' has already been " - "registered!".format(project_id)) - - project_type._PARENT_PLUGIN = parent_plugin - self._project_types[project_id] = project_type - - def get_project_types(self): - """ - Return available registered project types. - - Returns - ------- - dict - Project types dictionary. Keys are project type IDs and values - are project type classes. - """ - return self._project_types - - # --- Private API - # ------------------------------------------------------------------------- - def _new_editor(self, text): - self.editor.new(text=text) - - def _setup_editor_files(self, __unused): - self.editor.setup_open_files() - - def _set_path_in_editor(self, path): - self.editor.set_current_project_path(path) - - def _unset_path_in_editor(self, __unused): - self.editor.set_current_project_path() - - def _add_path_to_completions(self, path): - self.completions.project_path_update( - path, - update_kind=WorkspaceUpdateKind.ADDITION, - instance=self - ) - - def _remove_path_from_completions(self, path): - self.completions.project_path_update( - path, - update_kind=WorkspaceUpdateKind.DELETION, - instance=self - ) - - def _run_file_in_ipyconsole(self, fname): - self.ipyconsole.run_script( - fname, osp.dirname(fname), '', False, False, False, True, - False - ) +# -*- coding: utf-8 -*- +# +# Copyright © Spyder Project Contributors +# Licensed under the terms of the MIT License +# (see spyder/__init__.py for details) + +""" +Projects Plugin + +It handles closing, opening and switching among projetcs and also +updating the file tree explorer associated with a project +""" + +# Standard library imports +import configparser +import logging +import os +import os.path as osp +import shutil +from collections import OrderedDict + +# Third party imports +from qtpy.compat import getexistingdirectory +from qtpy.QtCore import Signal, Slot +from qtpy.QtWidgets import QInputDialog, QMessageBox + +# Local imports +from spyder.api.exceptions import SpyderAPIError +from spyder.api.plugin_registration.decorators import ( + on_plugin_available, on_plugin_teardown) +from spyder.api.translations import get_translation +from spyder.api.plugins import Plugins, SpyderDockablePlugin +from spyder.config.base import (get_home_dir, get_project_config_folder, + running_in_mac_app, running_under_pytest) +from spyder.py3compat import is_text_string, to_text_string +from spyder.utils import encoding +from spyder.utils.icon_manager import ima +from spyder.utils.misc import getcwd_or_home +from spyder.plugins.mainmenu.api import ApplicationMenus, ProjectsMenuSections +from spyder.plugins.projects.api import (BaseProjectType, EmptyProject, + WORKSPACE) +from spyder.plugins.projects.utils.watcher import WorkspaceWatcher +from spyder.plugins.projects.widgets.main_widget import ProjectExplorerWidget +from spyder.plugins.projects.widgets.projectdialog import ProjectDialog +from spyder.plugins.completion.api import ( + CompletionRequestTypes, FileChangeType, WorkspaceUpdateKind) +from spyder.plugins.completion.decorators import ( + request, handles, class_register) + +# Localization and logging +_ = get_translation("spyder") +logger = logging.getLogger(__name__) + + +class ProjectsMenuSubmenus: + RecentProjects = 'recent_projects' + + +class ProjectsActions: + NewProject = 'new_project_action' + OpenProject = 'open_project_action' + CloseProject = 'close_project_action' + DeleteProject = 'delete_project_action' + ClearRecentProjects = 'clear_recent_projects_action' + MaxRecent = 'max_recent_action' + + +class RecentProjectsMenuSections: + Recent = 'recent_section' + Extras = 'extras_section' + + +@class_register +class Projects(SpyderDockablePlugin): + """Projects plugin.""" + NAME = 'project_explorer' + CONF_SECTION = NAME + CONF_FILE = False + REQUIRES = [] + OPTIONAL = [Plugins.Completions, Plugins.IPythonConsole, Plugins.Editor, + Plugins.MainMenu] + WIDGET_CLASS = ProjectExplorerWidget + + # Signals + sig_project_created = Signal(str, str, object) + """ + This signal is emitted to request the Projects plugin the creation of a + project. + + Parameters + ---------- + project_path: str + Location of project. + project_type: str + Type of project as defined by project types. + project_packages: object + Package to install. Currently not in use. + """ + + sig_project_loaded = Signal(object) + """ + This signal is emitted when a project is loaded. + + Parameters + ---------- + project_path: object + Loaded project path. + """ + + sig_project_closed = Signal((object,), (bool,)) + """ + This signal is emitted when a project is closed. + + Parameters + ---------- + project_path: object + Closed project path (signature 1). + close_project: bool + This is emitted only when closing a project but not when switching + between projects (signature 2). + """ + + sig_pythonpath_changed = Signal() + """ + This signal is emitted when the Python path has changed. + """ + + def __init__(self, parent=None, configuration=None): + """Initialization.""" + super().__init__(parent, configuration) + self.recent_projects = self.get_conf('recent_projects', []) + self.current_active_project = None + self.latest_project = None + self.watcher = WorkspaceWatcher(self) + self.completions_available = False + self.get_widget().setup_project(self.get_active_project_path()) + self.watcher.connect_signals(self) + self._project_types = OrderedDict() + + # ---- SpyderDockablePlugin API + # ------------------------------------------------------------------------ + @staticmethod + def get_name(): + return _("Projects") + + def get_description(self): + return _("Create Spyder projects and manage their files.") + + def get_icon(self): + return self.create_icon('project') + + def on_initialize(self): + """Register plugin in Spyder's main window""" + widget = self.get_widget() + treewidget = widget.treewidget + + self.ipyconsole = None + self.editor = None + self.completions = None + + treewidget.sig_delete_project.connect(self.delete_project) + treewidget.sig_redirect_stdio_requested.connect( + self.sig_redirect_stdio_requested) + self.sig_switch_to_plugin_requested.connect( + lambda plugin, check: self.show_explorer()) + self.sig_project_loaded.connect(self.update_explorer) + + if self.main: + widget.sig_open_file_requested.connect(self.main.open_file) + self.main.project_path = self.get_pythonpath(at_start=True) + self.sig_project_loaded.connect( + lambda v: self.main.set_window_title()) + self.sig_project_closed.connect( + lambda v: self.main.set_window_title()) + self.main.restore_scrollbar_position.connect( + self.restore_scrollbar_position) + self.sig_pythonpath_changed.connect(self.main.pythonpath_changed) + + self.register_project_type(self, EmptyProject) + self.setup() + + @on_plugin_available(plugin=Plugins.Editor) + def on_editor_available(self): + self.editor = self.get_plugin(Plugins.Editor) + widget = self.get_widget() + treewidget = widget.treewidget + + treewidget.sig_open_file_requested.connect(self.editor.load) + treewidget.sig_removed.connect(self.editor.removed) + treewidget.sig_tree_removed.connect(self.editor.removed_tree) + treewidget.sig_renamed.connect(self.editor.renamed) + treewidget.sig_tree_renamed.connect(self.editor.renamed_tree) + treewidget.sig_module_created.connect(self.editor.new) + treewidget.sig_file_created.connect(self._new_editor) + + self.sig_project_loaded.connect(self._setup_editor_files) + self.sig_project_closed[bool].connect(self._setup_editor_files) + + self.editor.set_projects(self) + self.sig_project_loaded.connect(self._set_path_in_editor) + self.sig_project_closed.connect(self._unset_path_in_editor) + + @on_plugin_available(plugin=Plugins.Completions) + def on_completions_available(self): + self.completions = self.get_plugin(Plugins.Completions) + + # TODO: This is not necessary anymore due to us starting workspace + # services in the editor. However, we could restore it in the future. + # completions.sig_language_completions_available.connect( + # lambda settings, language: + # self.start_workspace_services()) + self.completions.sig_stop_completions.connect( + self.stop_workspace_services) + self.sig_project_loaded.connect(self._add_path_to_completions) + self.sig_project_closed.connect(self._remove_path_from_completions) + + @on_plugin_available(plugin=Plugins.IPythonConsole) + def on_ipython_console_available(self): + self.ipyconsole = self.get_plugin(Plugins.IPythonConsole) + widget = self.get_widget() + treewidget = widget.treewidget + treewidget.sig_open_interpreter_requested.connect( + self.ipyconsole.create_client_from_path) + treewidget.sig_run_requested.connect(self._run_file_in_ipyconsole) + + @on_plugin_available(plugin=Plugins.MainMenu) + def on_main_menu_available(self): + main_menu = self.get_plugin(Plugins.MainMenu) + new_project_action = self.get_action(ProjectsActions.NewProject) + open_project_action = self.get_action(ProjectsActions.OpenProject) + + projects_menu = main_menu.get_application_menu( + ApplicationMenus.Projects) + projects_menu.aboutToShow.connect(self.is_invalid_active_project) + + main_menu.add_item_to_application_menu( + new_project_action, + menu_id=ApplicationMenus.Projects, + section=ProjectsMenuSections.New) + + for item in [open_project_action, self.close_project_action, + self.delete_project_action]: + main_menu.add_item_to_application_menu( + item, + menu_id=ApplicationMenus.Projects, + section=ProjectsMenuSections.Open) + + main_menu.add_item_to_application_menu( + self.recent_project_menu, + menu_id=ApplicationMenus.Projects, + section=ProjectsMenuSections.Extras) + + @on_plugin_teardown(plugin=Plugins.Editor) + def on_editor_teardown(self): + self.editor = self.get_plugin(Plugins.Editor) + widget = self.get_widget() + treewidget = widget.treewidget + + treewidget.sig_open_file_requested.disconnect(self.editor.load) + treewidget.sig_removed.disconnect(self.editor.removed) + treewidget.sig_tree_removed.disconnect(self.editor.removed_tree) + treewidget.sig_renamed.disconnect(self.editor.renamed) + treewidget.sig_tree_renamed.disconnect(self.editor.renamed_tree) + treewidget.sig_module_created.disconnect(self.editor.new) + treewidget.sig_file_created.disconnect(self._new_editor) + + self.sig_project_loaded.disconnect(self._setup_editor_files) + self.sig_project_closed[bool].disconnect(self._setup_editor_files) + self.editor.set_projects(None) + self.sig_project_loaded.disconnect(self._set_path_in_editor) + self.sig_project_closed.disconnect(self._unset_path_in_editor) + + self.editor = None + + @on_plugin_teardown(plugin=Plugins.Completions) + def on_completions_teardown(self): + self.completions = self.get_plugin(Plugins.Completions) + + self.completions.sig_stop_completions.disconnect( + self.stop_workspace_services) + + self.sig_project_loaded.disconnect(self._add_path_to_completions) + self.sig_project_closed.disconnect(self._remove_path_from_completions) + + self.completions = None + + @on_plugin_teardown(plugin=Plugins.IPythonConsole) + def on_ipython_console_teardown(self): + self.ipyconsole = self.get_plugin(Plugins.IPythonConsole) + widget = self.get_widget() + treewidget = widget.treewidget + + treewidget.sig_open_interpreter_requested.disconnect( + self.ipyconsole.create_client_from_path) + treewidget.sig_run_requested.disconnect(self._run_file_in_ipyconsole) + + self._ipython_run_script = None + self.ipyconsole = None + + @on_plugin_teardown(plugin=Plugins.MainMenu) + def on_main_menu_teardown(self): + main_menu = self.get_plugin(Plugins.MainMenu) + main_menu.remove_application_menu(ApplicationMenus.Projects) + + def setup(self): + """Setup the plugin actions.""" + self.create_action( + ProjectsActions.NewProject, + text=_("New Project..."), + triggered=self.create_new_project) + + self.create_action( + ProjectsActions.OpenProject, + text=_("Open Project..."), + triggered=lambda v: self.open_project()) + + self.close_project_action = self.create_action( + ProjectsActions.CloseProject, + text=_("Close Project"), + triggered=self.close_project) + + self.delete_project_action = self.create_action( + ProjectsActions.DeleteProject, + text=_("Delete Project"), + triggered=self.delete_project) + + self.clear_recent_projects_action = self.create_action( + ProjectsActions.ClearRecentProjects, + text=_("Clear this list"), + triggered=self.clear_recent_projects) + + self.max_recent_action = self.create_action( + ProjectsActions.MaxRecent, + text=_("Maximum number of recent projects..."), + triggered=self.change_max_recent_projects) + + self.recent_project_menu = self.get_widget().create_menu( + ProjectsMenuSubmenus.RecentProjects, + _("Recent Projects") + ) + self.recent_project_menu.aboutToShow.connect(self.setup_menu_actions) + self.setup_menu_actions() + + def setup_menu_actions(self): + """Setup and update the menu actions.""" + if self.recent_projects: + for project in self.recent_projects: + if self.is_valid_project(project): + if os.name == 'nt': + name = project + else: + name = project.replace(get_home_dir(), '~') + try: + action = self.get_action(name) + except KeyError: + action = self.create_action( + name, + text=name, + icon=ima.icon('project'), + triggered=self.build_opener(project), + ) + self.get_widget().add_item_to_menu( + action, + menu=self.recent_project_menu, + section=RecentProjectsMenuSections.Recent) + + for item in [self.clear_recent_projects_action, + self.max_recent_action]: + self.get_widget().add_item_to_menu( + item, + menu=self.recent_project_menu, + section=RecentProjectsMenuSections.Extras) + self.update_project_actions() + + def update_project_actions(self): + """Update actions of the Projects menu""" + if self.recent_projects: + self.clear_recent_projects_action.setEnabled(True) + else: + self.clear_recent_projects_action.setEnabled(False) + + active = bool(self.get_active_project_path()) + self.close_project_action.setEnabled(active) + self.delete_project_action.setEnabled(active) + + def on_close(self, cancelable=False): + """Perform actions before parent main window is closed""" + self.save_config() + self.watcher.stop() + return True + + def unmaximize(self): + """Unmaximize the currently maximized plugin, if not self.""" + if self.main: + if (self.main.last_plugin is not None and + self.main.last_plugin._ismaximized and + self.main.last_plugin is not self): + self.main.maximize_dockwidget() + + def build_opener(self, project): + """Build function opening passed project""" + def opener(*args, **kwargs): + self.open_project(path=project) + return opener + + def on_mainwindow_visible(self): + # Open project passed on the command line or reopen last one. + cli_options = self.get_command_line_options() + initial_cwd = self._main.get_initial_working_directory() + + if cli_options.project is not None: + # This doesn't work for our Mac app + if not running_in_mac_app(): + logger.debug('Opening project from the command line') + project = osp.normpath( + osp.join(initial_cwd, cli_options.project) + ) + self.open_project( + project, + workdir=cli_options.working_directory + ) + else: + logger.debug('Reopening project from last session') + self.reopen_last_project() + + # ------ Public API ------------------------------------------------------- + @Slot() + def create_new_project(self): + """Create new project.""" + self.unmaximize() + dlg = ProjectDialog(self.get_widget(), + project_types=self.get_project_types()) + result = dlg.exec_() + data = dlg.project_data + root_path = data.get("root_path", None) + project_type = data.get("project_type", EmptyProject.ID) + + if result: + self._create_project(root_path, project_type_id=project_type) + dlg.close() + + def _create_project(self, root_path, project_type_id=EmptyProject.ID, + packages=None): + """Create a new project.""" + project_types = self.get_project_types() + if project_type_id in project_types: + project_type_class = project_types[project_type_id] + project = project_type_class( + root_path=root_path, + parent_plugin=project_type_class._PARENT_PLUGIN, + ) + + created_succesfully, message = project.create_project() + if not created_succesfully: + QMessageBox.warning( + self.get_widget(), "Project creation", message) + shutil.rmtree(root_path, ignore_errors=True) + return + + # TODO: In a subsequent PR return a value and emit based on that + self.sig_project_created.emit(root_path, project_type_id, packages) + self.open_project(path=root_path, project=project) + else: + if not running_under_pytest(): + QMessageBox.critical( + self.get_widget(), + _('Error'), + _("{} is not a registered Spyder project " + "type!").format(project_type_id) + ) + + def open_project(self, path=None, project=None, restart_consoles=True, + save_previous_files=True, workdir=None): + """Open the project located in `path`.""" + self.unmaximize() + if path is None: + basedir = get_home_dir() + path = getexistingdirectory(parent=self.get_widget(), + caption=_("Open project"), + basedir=basedir) + path = encoding.to_unicode_from_fs(path) + if not self.is_valid_project(path): + if path: + QMessageBox.critical( + self.get_widget(), + _('Error'), + _("%s is not a Spyder project!") % path, + ) + return + else: + path = encoding.to_unicode_from_fs(path) + + logger.debug(f'Opening project located at {path}') + + if project is None: + project_type_class = self._load_project_type_class(path) + project = project_type_class( + root_path=path, + parent_plugin=project_type_class._PARENT_PLUGIN, + ) + + # A project was not open before + if self.current_active_project is None: + if save_previous_files and self.editor is not None: + self.editor.save_open_files() + + if self.editor is not None: + self.set_conf('last_working_dir', getcwd_or_home(), + section='editor') + + if self.get_conf('visible_if_project_open'): + self.show_explorer() + else: + # We are switching projects + if self.editor is not None: + self.set_project_filenames(self.editor.get_open_filenames()) + + # TODO: Don't emit sig_project_closed when we support + # multiple workspaces. + self.sig_project_closed.emit( + self.current_active_project.root_path) + self.watcher.stop() + + self.current_active_project = project + self.latest_project = project + self.add_to_recent(path) + + self.set_conf('current_project_path', self.get_active_project_path()) + + self.setup_menu_actions() + if workdir and osp.isdir(workdir): + self.sig_project_loaded.emit(workdir) + else: + self.sig_project_loaded.emit(path) + self.sig_pythonpath_changed.emit() + self.watcher.start(path) + + if restart_consoles: + self.restart_consoles() + + open_successfully, message = project.open_project() + if not open_successfully: + QMessageBox.warning(self.get_widget(), "Project open", message) + + def close_project(self): + """ + Close current project and return to a window without an active + project + """ + if self.current_active_project: + self.unmaximize() + if self.editor is not None: + self.set_project_filenames( + self.editor.get_open_filenames()) + path = self.current_active_project.root_path + closed_sucessfully, message = ( + self.current_active_project.close_project()) + if not closed_sucessfully: + QMessageBox.warning( + self.get_widget(), "Project close", message) + + self.current_active_project = None + self.set_conf('current_project_path', None) + self.setup_menu_actions() + + self.sig_project_closed.emit(path) + self.sig_project_closed[bool].emit(True) + self.sig_pythonpath_changed.emit() + + # Hide pane. + self.set_conf('visible_if_project_open', + self.get_widget().isVisible()) + self.toggle_view(False) + + self.get_widget().clear() + self.restart_consoles() + self.watcher.stop() + + def delete_project(self): + """ + Delete the current project without deleting the files in the directory. + """ + if self.current_active_project: + self.unmaximize() + path = self.current_active_project.root_path + buttons = QMessageBox.Yes | QMessageBox.No + answer = QMessageBox.warning( + self.get_widget(), + _("Delete"), + _("Do you really want to delete {filename}?

    " + "Note: This action will only delete the project. " + "Its files are going to be preserved on disk." + ).format(filename=osp.basename(path)), + buttons) + if answer == QMessageBox.Yes: + try: + self.close_project() + shutil.rmtree(osp.join(path, '.spyproject')) + except EnvironmentError as error: + QMessageBox.critical( + self.get_widget(), + _("Project Explorer"), + _("Unable to delete {varpath}" + "

    The error message was:
    {error}" + ).format(varpath=path, error=to_text_string(error))) + + def clear_recent_projects(self): + """Clear the list of recent projects""" + self.recent_projects = [] + self.set_conf('recent_projects', self.recent_projects) + self.setup_menu_actions() + + def change_max_recent_projects(self): + """Change max recent projects entries.""" + + mrf, valid = QInputDialog.getInt( + self.get_widget(), + _('Projects'), + _('Maximum number of recent projects'), + self.get_conf('max_recent_projects'), + 1, + 35) + + if valid: + self.set_conf('max_recent_projects', mrf) + + def get_active_project(self): + """Get the active project""" + return self.current_active_project + + def reopen_last_project(self): + """ + Reopen the active project when Spyder was closed last time, if any + """ + current_project_path = self.get_conf('current_project_path', + default=None) + + # Needs a safer test of project existence! + if ( + current_project_path and + self.is_valid_project(current_project_path) + ): + cli_options = self.get_command_line_options() + self.open_project( + path=current_project_path, + restart_consoles=True, + save_previous_files=False, + workdir=cli_options.working_directory + ) + self.load_config() + + def get_project_filenames(self): + """Get the list of recent filenames of a project""" + recent_files = [] + if self.current_active_project: + recent_files = self.current_active_project.get_recent_files() + elif self.latest_project: + recent_files = self.latest_project.get_recent_files() + return recent_files + + def set_project_filenames(self, recent_files): + """Set the list of open file names in a project""" + if (self.current_active_project + and self.is_valid_project( + self.current_active_project.root_path)): + self.current_active_project.set_recent_files(recent_files) + + def get_active_project_path(self): + """Get path of the active project""" + active_project_path = None + if self.current_active_project: + active_project_path = self.current_active_project.root_path + return active_project_path + + def get_pythonpath(self, at_start=False): + """Get project path as a list to be added to PYTHONPATH""" + if at_start: + current_path = self.get_conf('current_project_path', + default=None) + else: + current_path = self.get_active_project_path() + if current_path is None: + return [] + else: + return [current_path] + + def get_last_working_dir(self): + """Get the path of the last working directory""" + return self.get_conf( + 'last_working_dir', section='editor', default=getcwd_or_home()) + + def save_config(self): + """ + Save configuration: opened projects & tree widget state. + + Also save whether dock widget is visible if a project is open. + """ + self.set_conf('recent_projects', self.recent_projects) + self.set_conf('expanded_state', + self.get_widget().treewidget.get_expanded_state()) + self.set_conf('scrollbar_position', + self.get_widget().treewidget.get_scrollbar_position()) + if self.current_active_project: + self.set_conf('visible_if_project_open', + self.get_widget().isVisible()) + + def load_config(self): + """Load configuration: opened projects & tree widget state""" + expanded_state = self.get_conf('expanded_state', None) + # Sometimes the expanded state option may be truncated in .ini file + # (for an unknown reason), in this case it would be converted to a + # string by 'userconfig': + if is_text_string(expanded_state): + expanded_state = None + if expanded_state is not None: + self.get_widget().treewidget.set_expanded_state(expanded_state) + + def restore_scrollbar_position(self): + """Restoring scrollbar position after main window is visible""" + scrollbar_pos = self.get_conf('scrollbar_position', None) + if scrollbar_pos is not None: + self.get_widget().treewidget.set_scrollbar_position(scrollbar_pos) + + def update_explorer(self): + """Update explorer tree""" + self.get_widget().setup_project(self.get_active_project_path()) + + def show_explorer(self): + """Show the explorer""" + if self.get_widget() is not None: + self.toggle_view(True) + self.get_widget().setVisible(True) + self.get_widget().raise_() + self.get_widget().update() + + def restart_consoles(self): + """Restart consoles when closing, opening and switching projects""" + if self.ipyconsole is not None: + self.ipyconsole.restart() + + def is_valid_project(self, path): + """Check if a directory is a valid Spyder project""" + spy_project_dir = osp.join(path, '.spyproject') + return osp.isdir(path) and osp.isdir(spy_project_dir) + + def is_invalid_active_project(self): + """Handle an invalid active project.""" + try: + path = self.get_active_project_path() + except AttributeError: + return + + if bool(path): + if not self.is_valid_project(path): + if path: + QMessageBox.critical( + self.get_widget(), + _('Error'), + _("{} is no longer a valid Spyder project! " + "Since it is the current active project, it will " + "be closed automatically.").format(path) + ) + self.close_project() + + def add_to_recent(self, project): + """ + Add an entry to recent projetcs + + We only maintain the list of the 10 most recent projects + """ + if project not in self.recent_projects: + self.recent_projects.insert(0, project) + if len(self.recent_projects) > self.get_conf('max_recent_projects'): + self.recent_projects.pop(-1) + + def start_workspace_services(self): + """Enable LSP workspace functionality.""" + self.completions_available = True + if self.current_active_project: + path = self.get_active_project_path() + self.notify_project_open(path) + + def stop_workspace_services(self, _language): + """Disable LSP workspace functionality.""" + self.completions_available = False + + def emit_request(self, method, params, requires_response): + """Send request/notification/response to all LSP servers.""" + params['requires_response'] = requires_response + params['response_instance'] = self + if self.completions: + self.completions.broadcast_notification(method, params) + + @Slot(str, dict) + def handle_response(self, method, params): + """Method dispatcher for LSP requests.""" + if method in self.handler_registry: + handler_name = self.handler_registry[method] + handler = getattr(self, handler_name) + handler(params) + + @Slot(str, str, bool) + @request(method=CompletionRequestTypes.WORKSPACE_WATCHED_FILES_UPDATE, + requires_response=False) + def file_moved(self, src_file, dest_file, is_dir): + """Notify LSP server about a file that is moved.""" + # LSP specification only considers file updates + if is_dir: + return + + deletion_entry = { + 'file': src_file, + 'kind': FileChangeType.DELETED + } + + addition_entry = { + 'file': dest_file, + 'kind': FileChangeType.CREATED + } + + entries = [addition_entry, deletion_entry] + params = { + 'params': entries + } + return params + + @request(method=CompletionRequestTypes.WORKSPACE_WATCHED_FILES_UPDATE, + requires_response=False) + @Slot(str, bool) + def file_created(self, src_file, is_dir): + """Notify LSP server about file creation.""" + if is_dir: + return + + params = { + 'params': [{ + 'file': src_file, + 'kind': FileChangeType.CREATED + }] + } + return params + + @request(method=CompletionRequestTypes.WORKSPACE_WATCHED_FILES_UPDATE, + requires_response=False) + @Slot(str, bool) + def file_deleted(self, src_file, is_dir): + """Notify LSP server about file deletion.""" + if is_dir: + return + + params = { + 'params': [{ + 'file': src_file, + 'kind': FileChangeType.DELETED + }] + } + return params + + @request(method=CompletionRequestTypes.WORKSPACE_WATCHED_FILES_UPDATE, + requires_response=False) + @Slot(str, bool) + def file_modified(self, src_file, is_dir): + """Notify LSP server about file modification.""" + if is_dir: + return + + params = { + 'params': [{ + 'file': src_file, + 'kind': FileChangeType.CHANGED + }] + } + return params + + @request(method=CompletionRequestTypes.WORKSPACE_FOLDERS_CHANGE, + requires_response=False) + def notify_project_open(self, path): + """Notify LSP server about project path availability.""" + params = { + 'folder': path, + 'instance': self, + 'kind': 'addition' + } + return params + + @request(method=CompletionRequestTypes.WORKSPACE_FOLDERS_CHANGE, + requires_response=False) + def notify_project_close(self, path): + """Notify LSP server to unregister project path.""" + params = { + 'folder': path, + 'instance': self, + 'kind': 'deletion' + } + return params + + @handles(CompletionRequestTypes.WORKSPACE_APPLY_EDIT) + @request(method=CompletionRequestTypes.WORKSPACE_APPLY_EDIT, + requires_response=False) + def handle_workspace_edit(self, params): + """Apply edits to multiple files and notify server about success.""" + edits = params['params'] + response = { + 'applied': False, + 'error': 'Not implemented', + 'language': edits['language'] + } + return response + + # --- New API: + # ------------------------------------------------------------------------ + def _load_project_type_class(self, path): + """ + Load a project type class from the config project folder directly. + + Notes + ----- + This is done directly, since using the EmptyProject would rewrite the + value in the constructor. If the project found has not been registered + as a valid project type, the EmptyProject type will be returned. + + Returns + ------- + spyder.plugins.projects.api.BaseProjectType + Loaded project type class. + """ + fpath = osp.join( + path, get_project_config_folder(), 'config', WORKSPACE + ".ini") + + project_type_id = EmptyProject.ID + if osp.isfile(fpath): + config = configparser.ConfigParser() + + # Catch any possible error when reading the workspace config file. + # Fixes spyder-ide/spyder#17621 + try: + config.read(fpath, encoding='utf-8') + except Exception: + pass + + # This is necessary to catch an error for projects created in + # Spyder 4 or older versions. + # Fixes spyder-ide/spyder17097 + try: + project_type_id = config[WORKSPACE].get( + "project_type", EmptyProject.ID) + except KeyError: + pass + + EmptyProject._PARENT_PLUGIN = self + project_types = self.get_project_types() + project_type_class = project_types.get(project_type_id, EmptyProject) + return project_type_class + + def register_project_type(self, parent_plugin, project_type): + """ + Register a new project type. + + Parameters + ---------- + parent_plugin: spyder.plugins.api.plugins.SpyderPluginV2 + The parent plugin instance making the project type registration. + project_type: spyder.plugins.projects.api.BaseProjectType + Project type to register. + """ + if not issubclass(project_type, BaseProjectType): + raise SpyderAPIError("A project type must subclass " + "BaseProjectType!") + + project_id = project_type.ID + if project_id in self._project_types: + raise SpyderAPIError("A project type id '{}' has already been " + "registered!".format(project_id)) + + project_type._PARENT_PLUGIN = parent_plugin + self._project_types[project_id] = project_type + + def get_project_types(self): + """ + Return available registered project types. + + Returns + ------- + dict + Project types dictionary. Keys are project type IDs and values + are project type classes. + """ + return self._project_types + + # --- Private API + # ------------------------------------------------------------------------- + def _new_editor(self, text): + self.editor.new(text=text) + + def _setup_editor_files(self, __unused): + self.editor.setup_open_files() + + def _set_path_in_editor(self, path): + self.editor.set_current_project_path(path) + + def _unset_path_in_editor(self, __unused): + self.editor.set_current_project_path() + + def _add_path_to_completions(self, path): + self.completions.project_path_update( + path, + update_kind=WorkspaceUpdateKind.ADDITION, + instance=self + ) + + def _remove_path_from_completions(self, path): + self.completions.project_path_update( + path, + update_kind=WorkspaceUpdateKind.DELETION, + instance=self + ) + + def _run_file_in_ipyconsole(self, fname): + self.ipyconsole.run_script( + fname, osp.dirname(fname), '', False, False, False, True, + False + ) diff --git a/spyder/plugins/projects/utils/config.py b/spyder/plugins/projects/utils/config.py index cd207ba146e..963d82e9a75 100644 --- a/spyder/plugins/projects/utils/config.py +++ b/spyder/plugins/projects/utils/config.py @@ -1,111 +1,111 @@ -# -*- coding: utf-8 -*- -# ----------------------------------------------------------------------------- -# Copyright © Spyder Project Contributors -# -# Licensed under the terms of the MIT License -# (see spyder/__init__.py for details) -# ----------------------------------------------------------------------------- -"""Configuration options for projects.""" - -# Local imports -from spyder.config.user import MultiUserConfig, UserConfig - - -# Constants -PROJECT_FILENAME = '.spyproj' -WORKSPACE = 'workspace' -CODESTYLE = 'codestyle' -ENCODING = 'encoding' -VCS = 'vcs' - - -# Project configuration defaults -PROJECT_DEFAULTS = [ - (WORKSPACE, - {'restore_data_on_startup': True, - 'save_data_on_exit': True, - 'save_history': True, - 'save_non_project_files': False, - } - ), - (CODESTYLE, - {'indentation': True, - 'edge_line': True, - 'edge_line_columns': '79', - } - ), - (VCS, - {'use_version_control': False, - 'version_control_system': '', - } - ), - (ENCODING, - {'text_encoding': 'utf-8', - } - ) -] - - -PROJECT_NAME_MAP = { - # Empty container object means use the rest of defaults - WORKSPACE: [], - # Splitting these files makes sense for projects, we might as well - # apply the same split for the app global config - # These options change on spyder startup or are tied to a specific OS, - # not good for version control - WORKSPACE: [ - (WORKSPACE, [ - 'restore_data_on_startup', - 'save_data_on_exit', - 'save_history', - 'save_non_project_files', - ], - ), - ], - CODESTYLE: [ - (CODESTYLE, [ - 'indentation', - 'edge_line', - 'edge_line_columns', - ], - ), - ], - VCS: [ - (VCS, [ - 'use_version_control', - 'version_control_system', - ], - ), - ], - ENCODING: [ - (ENCODING, [ - 'text_encoding', - ] - ), - ], -} - - -# ============================================================================= -# Config instance -# ============================================================================= -# IMPORTANT NOTES: -# 1. If you want to *change* the default value of a current option, you need to -# do a MINOR update in config version, e.g. from 3.0.0 to 3.1.0 -# 2. If you want to *remove* options that are no longer needed in our codebase, -# 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 -PROJECT_CONF_VERSION = '0.2.0' - - -class ProjectConfig(UserConfig): - """Plugin configuration handler.""" - - -class ProjectMultiConfig(MultiUserConfig): - """Plugin configuration handler with multifile support.""" - DEFAULT_FILE_NAME = WORKSPACE - - def get_config_class(self): - return ProjectConfig +# -*- coding: utf-8 -*- +# ----------------------------------------------------------------------------- +# Copyright © Spyder Project Contributors +# +# Licensed under the terms of the MIT License +# (see spyder/__init__.py for details) +# ----------------------------------------------------------------------------- +"""Configuration options for projects.""" + +# Local imports +from spyder.config.user import MultiUserConfig, UserConfig + + +# Constants +PROJECT_FILENAME = '.spyproj' +WORKSPACE = 'workspace' +CODESTYLE = 'codestyle' +ENCODING = 'encoding' +VCS = 'vcs' + + +# Project configuration defaults +PROJECT_DEFAULTS = [ + (WORKSPACE, + {'restore_data_on_startup': True, + 'save_data_on_exit': True, + 'save_history': True, + 'save_non_project_files': False, + } + ), + (CODESTYLE, + {'indentation': True, + 'edge_line': True, + 'edge_line_columns': '79', + } + ), + (VCS, + {'use_version_control': False, + 'version_control_system': '', + } + ), + (ENCODING, + {'text_encoding': 'utf-8', + } + ) +] + + +PROJECT_NAME_MAP = { + # Empty container object means use the rest of defaults + WORKSPACE: [], + # Splitting these files makes sense for projects, we might as well + # apply the same split for the app global config + # These options change on spyder startup or are tied to a specific OS, + # not good for version control + WORKSPACE: [ + (WORKSPACE, [ + 'restore_data_on_startup', + 'save_data_on_exit', + 'save_history', + 'save_non_project_files', + ], + ), + ], + CODESTYLE: [ + (CODESTYLE, [ + 'indentation', + 'edge_line', + 'edge_line_columns', + ], + ), + ], + VCS: [ + (VCS, [ + 'use_version_control', + 'version_control_system', + ], + ), + ], + ENCODING: [ + (ENCODING, [ + 'text_encoding', + ] + ), + ], +} + + +# ============================================================================= +# Config instance +# ============================================================================= +# IMPORTANT NOTES: +# 1. If you want to *change* the default value of a current option, you need to +# do a MINOR update in config version, e.g. from 3.0.0 to 3.1.0 +# 2. If you want to *remove* options that are no longer needed in our codebase, +# 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 +PROJECT_CONF_VERSION = '0.2.0' + + +class ProjectConfig(UserConfig): + """Plugin configuration handler.""" + + +class ProjectMultiConfig(MultiUserConfig): + """Plugin configuration handler with multifile support.""" + DEFAULT_FILE_NAME = WORKSPACE + + def get_config_class(self): + return ProjectConfig diff --git a/spyder/plugins/projects/widgets/__init__.py b/spyder/plugins/projects/widgets/__init__.py index cd58dcb7743..9f1b5513de0 100644 --- a/spyder/plugins/projects/widgets/__init__.py +++ b/spyder/plugins/projects/widgets/__init__.py @@ -1,7 +1,7 @@ -# -*- coding: utf-8 -*- -# ----------------------------------------------------------------------------- -# Copyright © Spyder Project Contributors -# -# Licensed under the terms of the MIT License -# (see spyder/__init__.py for details) -# ----------------------------------------------------------------------------- +# -*- coding: utf-8 -*- +# ----------------------------------------------------------------------------- +# Copyright © Spyder Project Contributors +# +# Licensed under the terms of the MIT License +# (see spyder/__init__.py for details) +# ----------------------------------------------------------------------------- diff --git a/spyder/plugins/projects/widgets/projectdialog.py b/spyder/plugins/projects/widgets/projectdialog.py index 3b6db05c680..56d18b7b376 100644 --- a/spyder/plugins/projects/widgets/projectdialog.py +++ b/spyder/plugins/projects/widgets/projectdialog.py @@ -1,269 +1,269 @@ -# -*- coding: utf-8 -*- -# ----------------------------------------------------------------------------- -# Copyright © Spyder Project Contributors -# -# Licensed under the terms of the MIT License -# (see spyder/__init__.py for details) -# ----------------------------------------------------------------------------- -"""Project creation dialog.""" - -# Standard library imports -import errno -import os.path as osp -import sys -import tempfile - -# Third party imports -from qtpy.compat import getexistingdirectory -from qtpy.QtCore import Qt, Signal -from qtpy.QtWidgets import (QComboBox, QDialog, QDialogButtonBox, QGridLayout, - QGroupBox, QHBoxLayout, QLabel, QLineEdit, - QPushButton, QRadioButton, QVBoxLayout) - -# Local imports -from spyder.config.base import _, get_home_dir -from spyder.utils.icon_manager import ima -from spyder.utils.qthelpers import create_toolbutton - - -def is_writable(path): - """Check if path has write access""" - try: - testfile = tempfile.TemporaryFile(dir=path) - testfile.close() - except OSError as e: - if e.errno == errno.EACCES: # 13 - return False - return True - - -class ProjectDialog(QDialog): - """Project creation dialog.""" - - sig_project_creation_requested = Signal(str, str, object) - """ - This signal is emitted to request the Projects plugin the creation of a - project. - - Parameters - ---------- - project_path: str - Location of project. - project_type: str - Type of project as defined by project types. - project_packages: object - Package to install. Currently not in use. - """ - - def __init__(self, parent, project_types): - """Project creation dialog.""" - super(ProjectDialog, self).__init__(parent=parent) - self.plugin = parent - self._project_types = project_types - self.project_data = {} - - self.setWindowFlags( - self.windowFlags() & ~Qt.WindowContextHelpButtonHint) - - self.project_name = None - self.location = get_home_dir() - - # Widgets - projects_url = "http://docs.spyder-ide.org/current/panes/projects.html" - self.description_label = QLabel( - _("Select a new or existing directory to create a new Spyder " - "project in it. To learn more about projects, take a look at " - "our documentation.").format(projects_url) - ) - self.description_label.setOpenExternalLinks(True) - self.description_label.setWordWrap(True) - - self.groupbox = QGroupBox() - self.radio_new_dir = QRadioButton(_("New directory")) - self.radio_from_dir = QRadioButton(_("Existing directory")) - - self.label_project_name = QLabel(_('Project name')) - self.label_location = QLabel(_('Location')) - self.label_project_type = QLabel(_('Project type')) - - self.text_project_name = QLineEdit() - self.text_location = QLineEdit(get_home_dir()) - self.combo_project_type = QComboBox() - - self.label_information = QLabel("") - self.label_information.hide() - - self.button_select_location = create_toolbutton( - self, - triggered=self.select_location, - icon=ima.icon('DirOpenIcon'), - tip=_("Select directory") - ) - self.button_cancel = QPushButton(_('Cancel')) - self.button_create = QPushButton(_('Create')) - - self.bbox = QDialogButtonBox(Qt.Horizontal) - self.bbox.addButton(self.button_cancel, QDialogButtonBox.ActionRole) - self.bbox.addButton(self.button_create, QDialogButtonBox.ActionRole) - - # Widget setup - self.radio_new_dir.setChecked(True) - self.text_location.setEnabled(True) - self.text_location.setReadOnly(True) - self.button_cancel.setDefault(True) - self.button_cancel.setAutoDefault(True) - self.button_create.setEnabled(False) - for (id_, name) in [(pt_id, pt.get_name()) for pt_id, pt - in project_types.items()]: - self.combo_project_type.addItem(name, id_) - - self.setWindowTitle(_('Create new project')) - - # Layouts - layout_top = QHBoxLayout() - layout_top.addWidget(self.radio_new_dir) - layout_top.addSpacing(15) - layout_top.addWidget(self.radio_from_dir) - layout_top.addSpacing(200) - self.groupbox.setLayout(layout_top) - - layout_grid = QGridLayout() - layout_grid.addWidget(self.label_project_name, 0, 0) - layout_grid.addWidget(self.text_project_name, 0, 1, 1, 2) - layout_grid.addWidget(self.label_location, 1, 0) - layout_grid.addWidget(self.text_location, 1, 1) - layout_grid.addWidget(self.button_select_location, 1, 2) - layout_grid.addWidget(self.label_project_type, 2, 0) - layout_grid.addWidget(self.combo_project_type, 2, 1, 1, 2) - layout_grid.addWidget(self.label_information, 3, 0, 1, 3) - - layout = QVBoxLayout() - layout.addWidget(self.description_label) - layout.addSpacing(3) - layout.addWidget(self.groupbox) - layout.addSpacing(8) - layout.addLayout(layout_grid) - layout.addSpacing(8) - layout.addWidget(self.bbox) - layout.setSizeConstraint(layout.SetFixedSize) - - self.setLayout(layout) - - # Signals and slots - self.button_create.clicked.connect(self.create_project) - self.button_cancel.clicked.connect(self.close) - self.radio_from_dir.clicked.connect(self.update_location) - self.radio_new_dir.clicked.connect(self.update_location) - self.text_project_name.textChanged.connect(self.update_location) - - def select_location(self): - """Select directory.""" - location = osp.normpath( - getexistingdirectory( - self, - _("Select directory"), - self.location, - ) - ) - - if location and location != '.': - if is_writable(location): - self.location = location - self.text_project_name.setText(osp.basename(location)) - self.update_location() - - def update_location(self, text=''): - """Update text of location and validate it.""" - msg = '' - path_validation = False - path = self.location - name = self.text_project_name.text().strip() - - # Setup - self.text_project_name.setEnabled(self.radio_new_dir.isChecked()) - self.label_information.setText('') - self.label_information.hide() - - if name and self.radio_new_dir.isChecked(): - # Allow to create projects only on new directories. - path = osp.join(self.location, name) - path_validation = not osp.isdir(path) - if not path_validation: - msg = _("This directory already exists!") - elif self.radio_from_dir.isChecked(): - # Allow to create projects in current directories that are not - # Spyder projects. - path = self.location - path_validation = not osp.isdir(osp.join(path, '.spyproject')) - if not path_validation: - msg = _("This directory is already a Spyder project!") - - # Set path in text_location - self.text_location.setText(path) - - # Validate project name with the method from the currently selected - # project. - project_type_id = self.combo_project_type.currentData() - validate_func = self._project_types[project_type_id].validate_name - project_name_validation, project_msg = validate_func(path, name) - if not project_name_validation: - if msg: - msg = msg + '\n\n' + project_msg - else: - msg = project_msg - - # Set message - if msg: - self.label_information.show() - self.label_information.setText('\n' + msg) - - # Allow to create project if validation was successful - validated = path_validation and project_name_validation - self.button_create.setEnabled(validated) - - # Set default state of buttons according to validation - # Fixes spyder-ide/spyder#16745 - if validated: - self.button_create.setDefault(True) - self.button_create.setAutoDefault(True) - else: - self.button_cancel.setDefault(True) - self.button_cancel.setAutoDefault(True) - - def create_project(self): - """Create project.""" - self.project_data = { - "root_path": self.text_location.text(), - "project_type": self.combo_project_type.currentData(), - } - self.sig_project_creation_requested.emit( - self.text_location.text(), - self.combo_project_type.currentData(), - [], - ) - self.accept() - - -def test(): - """Local test.""" - from spyder.utils.qthelpers import qapplication - from spyder.plugins.projects.api import BaseProjectType - - class MockProjectType(BaseProjectType): - - @staticmethod - def get_name(): - return "Boo" - - @staticmethod - def validate_name(path, name): - return False, "BOOM!" - - app = qapplication() - dlg = ProjectDialog(None, {"empty": MockProjectType}) - dlg.show() - sys.exit(app.exec_()) - - -if __name__ == "__main__": - test() +# -*- coding: utf-8 -*- +# ----------------------------------------------------------------------------- +# Copyright © Spyder Project Contributors +# +# Licensed under the terms of the MIT License +# (see spyder/__init__.py for details) +# ----------------------------------------------------------------------------- +"""Project creation dialog.""" + +# Standard library imports +import errno +import os.path as osp +import sys +import tempfile + +# Third party imports +from qtpy.compat import getexistingdirectory +from qtpy.QtCore import Qt, Signal +from qtpy.QtWidgets import (QComboBox, QDialog, QDialogButtonBox, QGridLayout, + QGroupBox, QHBoxLayout, QLabel, QLineEdit, + QPushButton, QRadioButton, QVBoxLayout) + +# Local imports +from spyder.config.base import _, get_home_dir +from spyder.utils.icon_manager import ima +from spyder.utils.qthelpers import create_toolbutton + + +def is_writable(path): + """Check if path has write access""" + try: + testfile = tempfile.TemporaryFile(dir=path) + testfile.close() + except OSError as e: + if e.errno == errno.EACCES: # 13 + return False + return True + + +class ProjectDialog(QDialog): + """Project creation dialog.""" + + sig_project_creation_requested = Signal(str, str, object) + """ + This signal is emitted to request the Projects plugin the creation of a + project. + + Parameters + ---------- + project_path: str + Location of project. + project_type: str + Type of project as defined by project types. + project_packages: object + Package to install. Currently not in use. + """ + + def __init__(self, parent, project_types): + """Project creation dialog.""" + super(ProjectDialog, self).__init__(parent=parent) + self.plugin = parent + self._project_types = project_types + self.project_data = {} + + self.setWindowFlags( + self.windowFlags() & ~Qt.WindowContextHelpButtonHint) + + self.project_name = None + self.location = get_home_dir() + + # Widgets + projects_url = "http://docs.spyder-ide.org/current/panes/projects.html" + self.description_label = QLabel( + _("Select a new or existing directory to create a new Spyder " + "project in it. To learn more about projects, take a look at " + "our documentation.").format(projects_url) + ) + self.description_label.setOpenExternalLinks(True) + self.description_label.setWordWrap(True) + + self.groupbox = QGroupBox() + self.radio_new_dir = QRadioButton(_("New directory")) + self.radio_from_dir = QRadioButton(_("Existing directory")) + + self.label_project_name = QLabel(_('Project name')) + self.label_location = QLabel(_('Location')) + self.label_project_type = QLabel(_('Project type')) + + self.text_project_name = QLineEdit() + self.text_location = QLineEdit(get_home_dir()) + self.combo_project_type = QComboBox() + + self.label_information = QLabel("") + self.label_information.hide() + + self.button_select_location = create_toolbutton( + self, + triggered=self.select_location, + icon=ima.icon('DirOpenIcon'), + tip=_("Select directory") + ) + self.button_cancel = QPushButton(_('Cancel')) + self.button_create = QPushButton(_('Create')) + + self.bbox = QDialogButtonBox(Qt.Horizontal) + self.bbox.addButton(self.button_cancel, QDialogButtonBox.ActionRole) + self.bbox.addButton(self.button_create, QDialogButtonBox.ActionRole) + + # Widget setup + self.radio_new_dir.setChecked(True) + self.text_location.setEnabled(True) + self.text_location.setReadOnly(True) + self.button_cancel.setDefault(True) + self.button_cancel.setAutoDefault(True) + self.button_create.setEnabled(False) + for (id_, name) in [(pt_id, pt.get_name()) for pt_id, pt + in project_types.items()]: + self.combo_project_type.addItem(name, id_) + + self.setWindowTitle(_('Create new project')) + + # Layouts + layout_top = QHBoxLayout() + layout_top.addWidget(self.radio_new_dir) + layout_top.addSpacing(15) + layout_top.addWidget(self.radio_from_dir) + layout_top.addSpacing(200) + self.groupbox.setLayout(layout_top) + + layout_grid = QGridLayout() + layout_grid.addWidget(self.label_project_name, 0, 0) + layout_grid.addWidget(self.text_project_name, 0, 1, 1, 2) + layout_grid.addWidget(self.label_location, 1, 0) + layout_grid.addWidget(self.text_location, 1, 1) + layout_grid.addWidget(self.button_select_location, 1, 2) + layout_grid.addWidget(self.label_project_type, 2, 0) + layout_grid.addWidget(self.combo_project_type, 2, 1, 1, 2) + layout_grid.addWidget(self.label_information, 3, 0, 1, 3) + + layout = QVBoxLayout() + layout.addWidget(self.description_label) + layout.addSpacing(3) + layout.addWidget(self.groupbox) + layout.addSpacing(8) + layout.addLayout(layout_grid) + layout.addSpacing(8) + layout.addWidget(self.bbox) + layout.setSizeConstraint(layout.SetFixedSize) + + self.setLayout(layout) + + # Signals and slots + self.button_create.clicked.connect(self.create_project) + self.button_cancel.clicked.connect(self.close) + self.radio_from_dir.clicked.connect(self.update_location) + self.radio_new_dir.clicked.connect(self.update_location) + self.text_project_name.textChanged.connect(self.update_location) + + def select_location(self): + """Select directory.""" + location = osp.normpath( + getexistingdirectory( + self, + _("Select directory"), + self.location, + ) + ) + + if location and location != '.': + if is_writable(location): + self.location = location + self.text_project_name.setText(osp.basename(location)) + self.update_location() + + def update_location(self, text=''): + """Update text of location and validate it.""" + msg = '' + path_validation = False + path = self.location + name = self.text_project_name.text().strip() + + # Setup + self.text_project_name.setEnabled(self.radio_new_dir.isChecked()) + self.label_information.setText('') + self.label_information.hide() + + if name and self.radio_new_dir.isChecked(): + # Allow to create projects only on new directories. + path = osp.join(self.location, name) + path_validation = not osp.isdir(path) + if not path_validation: + msg = _("This directory already exists!") + elif self.radio_from_dir.isChecked(): + # Allow to create projects in current directories that are not + # Spyder projects. + path = self.location + path_validation = not osp.isdir(osp.join(path, '.spyproject')) + if not path_validation: + msg = _("This directory is already a Spyder project!") + + # Set path in text_location + self.text_location.setText(path) + + # Validate project name with the method from the currently selected + # project. + project_type_id = self.combo_project_type.currentData() + validate_func = self._project_types[project_type_id].validate_name + project_name_validation, project_msg = validate_func(path, name) + if not project_name_validation: + if msg: + msg = msg + '\n\n' + project_msg + else: + msg = project_msg + + # Set message + if msg: + self.label_information.show() + self.label_information.setText('\n' + msg) + + # Allow to create project if validation was successful + validated = path_validation and project_name_validation + self.button_create.setEnabled(validated) + + # Set default state of buttons according to validation + # Fixes spyder-ide/spyder#16745 + if validated: + self.button_create.setDefault(True) + self.button_create.setAutoDefault(True) + else: + self.button_cancel.setDefault(True) + self.button_cancel.setAutoDefault(True) + + def create_project(self): + """Create project.""" + self.project_data = { + "root_path": self.text_location.text(), + "project_type": self.combo_project_type.currentData(), + } + self.sig_project_creation_requested.emit( + self.text_location.text(), + self.combo_project_type.currentData(), + [], + ) + self.accept() + + +def test(): + """Local test.""" + from spyder.utils.qthelpers import qapplication + from spyder.plugins.projects.api import BaseProjectType + + class MockProjectType(BaseProjectType): + + @staticmethod + def get_name(): + return "Boo" + + @staticmethod + def validate_name(path, name): + return False, "BOOM!" + + app = qapplication() + dlg = ProjectDialog(None, {"empty": MockProjectType}) + dlg.show() + sys.exit(app.exec_()) + + +if __name__ == "__main__": + test() diff --git a/spyder/plugins/projects/widgets/projectexplorer.py b/spyder/plugins/projects/widgets/projectexplorer.py index 6988f1fdc45..c7cbb4383ec 100644 --- a/spyder/plugins/projects/widgets/projectexplorer.py +++ b/spyder/plugins/projects/widgets/projectexplorer.py @@ -1,349 +1,349 @@ -# -*- coding: utf-8 -*- -# -# Copyright © Spyder Project Contributors -# Licensed under the terms of the MIT License -# (see spyder/__init__.py for details) - -"""Project Explorer""" - -# pylint: disable=C0103 - -# Standard library imports -from __future__ import print_function - -import os -import os.path as osp -import shutil - -# Third party imports -from qtpy.QtCore import QSortFilterProxyModel, Qt, Signal, Slot -from qtpy.QtWidgets import QAbstractItemView, QHeaderView, QMessageBox - -# Local imports -from spyder.api.translations import get_translation -from spyder.py3compat import to_text_string -from spyder.utils import misc -from spyder.plugins.explorer.widgets.explorer import DirView - -_ = get_translation('spyder') - - -class ProxyModel(QSortFilterProxyModel): - """Proxy model to filter tree view.""" - - PATHS_TO_HIDE = [ - # Useful paths - '.spyproject', - '__pycache__', - '.ipynb_checkpoints', - # VCS paths - '.git', - '.hg', - '.svn', - # Others - '.pytest_cache', - '.DS_Store', - 'Thumbs.db', - '.directory' - ] - - PATHS_TO_SHOW = [ - '.github' - ] - - def __init__(self, parent): - """Initialize the proxy model.""" - super(ProxyModel, self).__init__(parent) - self.root_path = None - self.path_list = [] - self.setDynamicSortFilter(True) - - def setup_filter(self, root_path, path_list): - """ - Setup proxy model filter parameters. - - Parameters - ---------- - root_path: str - Root path of the proxy model. - path_list: list - List with all the paths. - """ - self.root_path = osp.normpath(str(root_path)) - self.path_list = [osp.normpath(str(p)) for p in path_list] - self.invalidateFilter() - - def sort(self, column, order=Qt.AscendingOrder): - """Reimplement Qt method.""" - self.sourceModel().sort(column, order) - - def filterAcceptsRow(self, row, parent_index): - """Reimplement Qt method.""" - if self.root_path is None: - return True - index = self.sourceModel().index(row, 0, parent_index) - path = osp.normcase(osp.normpath( - str(self.sourceModel().filePath(index)))) - - if osp.normcase(self.root_path).startswith(path): - # This is necessary because parent folders need to be scanned - return True - else: - for p in [osp.normcase(p) for p in self.path_list]: - if path == p or path.startswith(p + os.sep): - if not any([path.endswith(os.sep + d) - for d in self.PATHS_TO_SHOW]): - if any([path.endswith(os.sep + d) - for d in self.PATHS_TO_HIDE]): - return False - else: - return True - else: - return True - else: - return False - - def data(self, index, role): - """Show tooltip with full path only for the root directory.""" - if role == Qt.ToolTipRole: - root_dir = self.path_list[0].split(osp.sep)[-1] - if index.data() == root_dir: - return osp.join(self.root_path, root_dir) - return QSortFilterProxyModel.data(self, index, role) - - def type(self, index): - """ - Returns the type of file for the given index. - - Parameters - ---------- - index: int - Given index to search its type. - """ - return self.sourceModel().type(self.mapToSource(index)) - - -class FilteredDirView(DirView): - """Filtered file/directory tree view.""" - def __init__(self, parent=None): - """Initialize the filtered dir view.""" - super().__init__(parent) - self.proxymodel = None - self.setup_proxy_model() - self.root_path = None - - # ---- Model - def setup_proxy_model(self): - """Setup proxy model.""" - self.proxymodel = ProxyModel(self) - self.proxymodel.setSourceModel(self.fsmodel) - - def install_model(self): - """Install proxy model.""" - if self.root_path is not None: - self.setModel(self.proxymodel) - - def set_root_path(self, root_path): - """ - Set root path. - - Parameters - ---------- - root_path: str - New path directory. - """ - self.root_path = root_path - self.install_model() - index = self.fsmodel.setRootPath(root_path) - self.proxymodel.setup_filter(self.root_path, []) - self.setRootIndex(self.proxymodel.mapFromSource(index)) - - def get_index(self, filename): - """ - Return index associated with filename. - - Parameters - ---------- - filename: str - String with the filename. - """ - index = self.fsmodel.index(filename) - if index.isValid() and index.model() is self.fsmodel: - return self.proxymodel.mapFromSource(index) - - def set_folder_names(self, folder_names): - """ - Set folder names - - Parameters - ---------- - folder_names: list - List with the folder names. - """ - assert self.root_path is not None - path_list = [osp.join(self.root_path, dirname) - for dirname in folder_names] - self.proxymodel.setup_filter(self.root_path, path_list) - - def get_filename(self, index): - """ - Return filename from index - - Parameters - ---------- - index: int - Index of the list of filenames - """ - if index: - path = self.fsmodel.filePath(self.proxymodel.mapToSource(index)) - return osp.normpath(str(path)) - - def setup_project_view(self): - """Setup view for projects.""" - for i in [1, 2, 3]: - self.hideColumn(i) - self.setHeaderHidden(True) - - # ---- Events - def directory_clicked(self, dirname, index): - if index and index.isValid(): - if self.get_conf('single_click_to_open'): - state = not self.isExpanded(index) - else: - state = self.isExpanded(index) - self.setExpanded(index, state) - - -class ProjectExplorerTreeWidget(FilteredDirView): - """Explorer tree widget""" - - sig_delete_project = Signal() - - def __init__(self, parent, show_hscrollbar=True): - FilteredDirView.__init__(self, parent) - self.last_folder = None - self.setSelectionMode(FilteredDirView.ExtendedSelection) - self.show_hscrollbar = show_hscrollbar - - # Enable drag & drop events - self.setDragEnabled(True) - self.setDragDropMode(FilteredDirView.DragDrop) - - # ------Public API--------------------------------------------------------- - @Slot(bool) - def toggle_hscrollbar(self, checked): - """Toggle horizontal scrollbar""" - self.set_conf('show_hscrollbar', checked) - self.show_hscrollbar = checked - self.header().setStretchLastSection(not checked) - self.header().setHorizontalScrollMode(QAbstractItemView.ScrollPerPixel) - self.header().setSectionResizeMode(QHeaderView.ResizeToContents) - - # ---- Internal drag & drop - def dragMoveEvent(self, event): - """Reimplement Qt method""" - index = self.indexAt(event.pos()) - if index: - dst = self.get_filename(index) - if osp.isdir(dst): - event.acceptProposedAction() - else: - event.ignore() - else: - event.ignore() - - def dropEvent(self, event): - """Reimplement Qt method""" - event.ignore() - action = event.dropAction() - if action not in (Qt.MoveAction, Qt.CopyAction): - return - - # QTreeView must not remove the source items even in MoveAction mode: - # event.setDropAction(Qt.CopyAction) - - dst = self.get_filename(self.indexAt(event.pos())) - yes_to_all, no_to_all = None, None - src_list = [to_text_string(url.toString()) - for url in event.mimeData().urls()] - if len(src_list) > 1: - buttons = (QMessageBox.Yes | QMessageBox.YesToAll | - QMessageBox.No | QMessageBox.NoToAll | - QMessageBox.Cancel) - else: - buttons = QMessageBox.Yes | QMessageBox.No - for src in src_list: - if src == dst: - continue - dst_fname = osp.join(dst, osp.basename(src)) - if osp.exists(dst_fname): - if yes_to_all is not None or no_to_all is not None: - if no_to_all: - continue - elif osp.isfile(dst_fname): - answer = QMessageBox.warning( - self, - _('Project explorer'), - _('File %s already exists.
    ' - 'Do you want to overwrite it?') % dst_fname, - buttons - ) - - if answer == QMessageBox.No: - continue - elif answer == QMessageBox.Cancel: - break - elif answer == QMessageBox.YesToAll: - yes_to_all = True - elif answer == QMessageBox.NoToAll: - no_to_all = True - continue - else: - QMessageBox.critical( - self, - _('Project explorer'), - _('Folder %s already exists.') % dst_fname, - QMessageBox.Ok - ) - event.setDropAction(Qt.CopyAction) - return - try: - if action == Qt.CopyAction: - if osp.isfile(src): - shutil.copy(src, dst) - else: - shutil.copytree(src, dst) - else: - if osp.isfile(src): - misc.move_file(src, dst) - else: - shutil.move(src, dst) - self.parent_widget.removed.emit(src) - except EnvironmentError as error: - if action == Qt.CopyAction: - action_str = _('copy') - else: - action_str = _('move') - QMessageBox.critical( - self, - _("Project Explorer"), - _("Unable to %s %s" - "

    Error message:
    %s") % (action_str, src, - str(error)) - ) - - @Slot() - def delete(self, fnames=None): - """Delete files""" - if fnames is None: - fnames = self.get_selected_filenames() - multiple = len(fnames) > 1 - yes_to_all = None - for fname in fnames: - if fname == self.proxymodel.path_list[0]: - self.sig_delete_project.emit() - else: - yes_to_all = self.delete_file(fname, multiple, yes_to_all) - if yes_to_all is not None and not yes_to_all: - # Canceled - break +# -*- coding: utf-8 -*- +# +# Copyright © Spyder Project Contributors +# Licensed under the terms of the MIT License +# (see spyder/__init__.py for details) + +"""Project Explorer""" + +# pylint: disable=C0103 + +# Standard library imports +from __future__ import print_function + +import os +import os.path as osp +import shutil + +# Third party imports +from qtpy.QtCore import QSortFilterProxyModel, Qt, Signal, Slot +from qtpy.QtWidgets import QAbstractItemView, QHeaderView, QMessageBox + +# Local imports +from spyder.api.translations import get_translation +from spyder.py3compat import to_text_string +from spyder.utils import misc +from spyder.plugins.explorer.widgets.explorer import DirView + +_ = get_translation('spyder') + + +class ProxyModel(QSortFilterProxyModel): + """Proxy model to filter tree view.""" + + PATHS_TO_HIDE = [ + # Useful paths + '.spyproject', + '__pycache__', + '.ipynb_checkpoints', + # VCS paths + '.git', + '.hg', + '.svn', + # Others + '.pytest_cache', + '.DS_Store', + 'Thumbs.db', + '.directory' + ] + + PATHS_TO_SHOW = [ + '.github' + ] + + def __init__(self, parent): + """Initialize the proxy model.""" + super(ProxyModel, self).__init__(parent) + self.root_path = None + self.path_list = [] + self.setDynamicSortFilter(True) + + def setup_filter(self, root_path, path_list): + """ + Setup proxy model filter parameters. + + Parameters + ---------- + root_path: str + Root path of the proxy model. + path_list: list + List with all the paths. + """ + self.root_path = osp.normpath(str(root_path)) + self.path_list = [osp.normpath(str(p)) for p in path_list] + self.invalidateFilter() + + def sort(self, column, order=Qt.AscendingOrder): + """Reimplement Qt method.""" + self.sourceModel().sort(column, order) + + def filterAcceptsRow(self, row, parent_index): + """Reimplement Qt method.""" + if self.root_path is None: + return True + index = self.sourceModel().index(row, 0, parent_index) + path = osp.normcase(osp.normpath( + str(self.sourceModel().filePath(index)))) + + if osp.normcase(self.root_path).startswith(path): + # This is necessary because parent folders need to be scanned + return True + else: + for p in [osp.normcase(p) for p in self.path_list]: + if path == p or path.startswith(p + os.sep): + if not any([path.endswith(os.sep + d) + for d in self.PATHS_TO_SHOW]): + if any([path.endswith(os.sep + d) + for d in self.PATHS_TO_HIDE]): + return False + else: + return True + else: + return True + else: + return False + + def data(self, index, role): + """Show tooltip with full path only for the root directory.""" + if role == Qt.ToolTipRole: + root_dir = self.path_list[0].split(osp.sep)[-1] + if index.data() == root_dir: + return osp.join(self.root_path, root_dir) + return QSortFilterProxyModel.data(self, index, role) + + def type(self, index): + """ + Returns the type of file for the given index. + + Parameters + ---------- + index: int + Given index to search its type. + """ + return self.sourceModel().type(self.mapToSource(index)) + + +class FilteredDirView(DirView): + """Filtered file/directory tree view.""" + def __init__(self, parent=None): + """Initialize the filtered dir view.""" + super().__init__(parent) + self.proxymodel = None + self.setup_proxy_model() + self.root_path = None + + # ---- Model + def setup_proxy_model(self): + """Setup proxy model.""" + self.proxymodel = ProxyModel(self) + self.proxymodel.setSourceModel(self.fsmodel) + + def install_model(self): + """Install proxy model.""" + if self.root_path is not None: + self.setModel(self.proxymodel) + + def set_root_path(self, root_path): + """ + Set root path. + + Parameters + ---------- + root_path: str + New path directory. + """ + self.root_path = root_path + self.install_model() + index = self.fsmodel.setRootPath(root_path) + self.proxymodel.setup_filter(self.root_path, []) + self.setRootIndex(self.proxymodel.mapFromSource(index)) + + def get_index(self, filename): + """ + Return index associated with filename. + + Parameters + ---------- + filename: str + String with the filename. + """ + index = self.fsmodel.index(filename) + if index.isValid() and index.model() is self.fsmodel: + return self.proxymodel.mapFromSource(index) + + def set_folder_names(self, folder_names): + """ + Set folder names + + Parameters + ---------- + folder_names: list + List with the folder names. + """ + assert self.root_path is not None + path_list = [osp.join(self.root_path, dirname) + for dirname in folder_names] + self.proxymodel.setup_filter(self.root_path, path_list) + + def get_filename(self, index): + """ + Return filename from index + + Parameters + ---------- + index: int + Index of the list of filenames + """ + if index: + path = self.fsmodel.filePath(self.proxymodel.mapToSource(index)) + return osp.normpath(str(path)) + + def setup_project_view(self): + """Setup view for projects.""" + for i in [1, 2, 3]: + self.hideColumn(i) + self.setHeaderHidden(True) + + # ---- Events + def directory_clicked(self, dirname, index): + if index and index.isValid(): + if self.get_conf('single_click_to_open'): + state = not self.isExpanded(index) + else: + state = self.isExpanded(index) + self.setExpanded(index, state) + + +class ProjectExplorerTreeWidget(FilteredDirView): + """Explorer tree widget""" + + sig_delete_project = Signal() + + def __init__(self, parent, show_hscrollbar=True): + FilteredDirView.__init__(self, parent) + self.last_folder = None + self.setSelectionMode(FilteredDirView.ExtendedSelection) + self.show_hscrollbar = show_hscrollbar + + # Enable drag & drop events + self.setDragEnabled(True) + self.setDragDropMode(FilteredDirView.DragDrop) + + # ------Public API--------------------------------------------------------- + @Slot(bool) + def toggle_hscrollbar(self, checked): + """Toggle horizontal scrollbar""" + self.set_conf('show_hscrollbar', checked) + self.show_hscrollbar = checked + self.header().setStretchLastSection(not checked) + self.header().setHorizontalScrollMode(QAbstractItemView.ScrollPerPixel) + self.header().setSectionResizeMode(QHeaderView.ResizeToContents) + + # ---- Internal drag & drop + def dragMoveEvent(self, event): + """Reimplement Qt method""" + index = self.indexAt(event.pos()) + if index: + dst = self.get_filename(index) + if osp.isdir(dst): + event.acceptProposedAction() + else: + event.ignore() + else: + event.ignore() + + def dropEvent(self, event): + """Reimplement Qt method""" + event.ignore() + action = event.dropAction() + if action not in (Qt.MoveAction, Qt.CopyAction): + return + + # QTreeView must not remove the source items even in MoveAction mode: + # event.setDropAction(Qt.CopyAction) + + dst = self.get_filename(self.indexAt(event.pos())) + yes_to_all, no_to_all = None, None + src_list = [to_text_string(url.toString()) + for url in event.mimeData().urls()] + if len(src_list) > 1: + buttons = (QMessageBox.Yes | QMessageBox.YesToAll | + QMessageBox.No | QMessageBox.NoToAll | + QMessageBox.Cancel) + else: + buttons = QMessageBox.Yes | QMessageBox.No + for src in src_list: + if src == dst: + continue + dst_fname = osp.join(dst, osp.basename(src)) + if osp.exists(dst_fname): + if yes_to_all is not None or no_to_all is not None: + if no_to_all: + continue + elif osp.isfile(dst_fname): + answer = QMessageBox.warning( + self, + _('Project explorer'), + _('File %s already exists.
    ' + 'Do you want to overwrite it?') % dst_fname, + buttons + ) + + if answer == QMessageBox.No: + continue + elif answer == QMessageBox.Cancel: + break + elif answer == QMessageBox.YesToAll: + yes_to_all = True + elif answer == QMessageBox.NoToAll: + no_to_all = True + continue + else: + QMessageBox.critical( + self, + _('Project explorer'), + _('Folder %s already exists.') % dst_fname, + QMessageBox.Ok + ) + event.setDropAction(Qt.CopyAction) + return + try: + if action == Qt.CopyAction: + if osp.isfile(src): + shutil.copy(src, dst) + else: + shutil.copytree(src, dst) + else: + if osp.isfile(src): + misc.move_file(src, dst) + else: + shutil.move(src, dst) + self.parent_widget.removed.emit(src) + except EnvironmentError as error: + if action == Qt.CopyAction: + action_str = _('copy') + else: + action_str = _('move') + QMessageBox.critical( + self, + _("Project Explorer"), + _("Unable to %s %s" + "

    Error message:
    %s") % (action_str, src, + str(error)) + ) + + @Slot() + def delete(self, fnames=None): + """Delete files""" + if fnames is None: + fnames = self.get_selected_filenames() + multiple = len(fnames) > 1 + yes_to_all = None + for fname in fnames: + if fname == self.proxymodel.path_list[0]: + self.sig_delete_project.emit() + else: + yes_to_all = self.delete_file(fname, multiple, yes_to_all) + if yes_to_all is not None and not yes_to_all: + # Canceled + break diff --git a/spyder/plugins/pylint/main_widget.py b/spyder/plugins/pylint/main_widget.py index 5f5cb9df794..6a2293f8f94 100644 --- a/spyder/plugins/pylint/main_widget.py +++ b/spyder/plugins/pylint/main_widget.py @@ -1,985 +1,985 @@ -# -*- coding: utf-8 -*- -# -# Copyright © Spyder Project Contributors -# Licensed under the terms of the MIT License -# (see spyder/__init__.py for details) - -"""Pylint widget.""" - -# pylint: disable=C0103 -# pylint: disable=R0903 -# pylint: disable=R0911 -# pylint: disable=R0201 - -# Standard library imports -import os -import os.path as osp -import pickle -import re -import sys -import time - -# Third party imports -import pylint -from qtpy.compat import getopenfilename -from qtpy.QtCore import (QByteArray, QProcess, QProcessEnvironment, Signal, - Slot) -from qtpy.QtWidgets import (QInputDialog, QLabel, QMessageBox, QTreeWidgetItem, - QVBoxLayout) - -# Local imports -from spyder.api.config.decorators import on_conf_change -from spyder.api.translations import get_translation -from spyder.api.widgets.main_widget import PluginMainWidget -from spyder.config.base import get_conf_path, running_in_mac_app -from spyder.plugins.pylint.utils import get_pylintrc_path -from spyder.plugins.variableexplorer.widgets.texteditor import TextEditor -from spyder.utils.icon_manager import ima -from spyder.utils.misc import getcwd_or_home, get_home_dir -from spyder.utils.palette import QStylePalette, SpyderPalette -from spyder.widgets.comboboxes import (PythonModulesComboBox, - is_module_or_package) -from spyder.widgets.onecolumntree import OneColumnTree, OneColumnTreeActions - -# Localization -_ = get_translation("spyder") - - -# --- Constants -# ---------------------------------------------------------------------------- -PYLINT_VER = pylint.__version__ -MIN_HISTORY_ENTRIES = 5 -MAX_HISTORY_ENTRIES = 100 -DANGER_COLOR = SpyderPalette.COLOR_ERROR_1 -WARNING_COLOR = SpyderPalette.COLOR_WARN_1 -SUCCESS_COLOR = SpyderPalette.COLOR_SUCCESS_1 - - -# TODO: There should be some palette from the appearance plugin so this -# is easier to use -MAIN_TEXT_COLOR = QStylePalette.COLOR_TEXT_1 -MAIN_PREVRATE_COLOR = QStylePalette.COLOR_TEXT_1 - - - -class PylintWidgetActions: - ChangeHistory = "change_history_depth_action" - RunCodeAnalysis = "run_analysis_action" - BrowseFile = "browse_action" - ShowLog = "log_action" - - -class PylintWidgetOptionsMenuSections: - Global = "global_section" - Section = "section_section" - History = "history_section" - - -class PylintWidgetMainToolbarSections: - Main = "main_section" - - -class PylintWidgetToolbarItems: - FileComboBox = 'file_combo' - RateLabel = 'rate_label' - DateLabel = 'date_label' - Stretcher1 = 'stretcher_1' - Stretcher2 = 'stretcher_2' - - -# ---- Items -class CategoryItem(QTreeWidgetItem): - """ - Category item for results. - - Notes - ----- - Possible categories are Convention, Refactor, Warning and Error. - """ - - CATEGORIES = { - "Convention": { - 'translation_string': _("Convention"), - 'icon': ima.icon("convention") - }, - "Refactor": { - 'translation_string': _("Refactor"), - 'icon': ima.icon("refactor") - }, - "Warning": { - 'translation_string': _("Warning"), - 'icon': ima.icon("warning") - }, - "Error": { - 'translation_string': _("Error"), - 'icon': ima.icon("error") - } - } - - def __init__(self, parent, category, number_of_messages): - # Messages string to append to category. - if number_of_messages > 1 or number_of_messages == 0: - messages = _('messages') - else: - messages = _('message') - - # Category title. - title = self.CATEGORIES[category]['translation_string'] - title += f" ({number_of_messages} {messages})" - - super().__init__(parent, [title], QTreeWidgetItem.Type) - - # Set icon - icon = self.CATEGORIES[category]['icon'] - self.setIcon(0, icon) - - -# ---- Widgets -# ---------------------------------------------------------------------------- -# TODO: display results on 3 columns instead of 1: msg_id, lineno, message -class ResultsTree(OneColumnTree): - - sig_edit_goto_requested = Signal(str, int, str) - """ - This signal will request to open a file in a given row and column - using a code editor. - - Parameters - ---------- - path: str - Path to file. - row: int - Cursor starting row position. - word: str - Word to select on given row. - """ - - def __init__(self, parent): - super().__init__(parent) - self.filename = None - self.results = None - self.data = None - self.set_title("") - - def activated(self, item): - """Double-click event""" - data = self.data.get(id(item)) - if data is not None: - fname, lineno = data - self.sig_edit_goto_requested.emit(fname, lineno, "") - - def clicked(self, item): - """Click event.""" - if isinstance(item, CategoryItem): - if item.isExpanded(): - self.collapseItem(item) - else: - self.expandItem(item) - else: - self.activated(item) - - def clear_results(self): - self.clear() - self.set_title("") - - def set_results(self, filename, results): - self.filename = filename - self.results = results - self.refresh() - - def refresh(self): - title = _("Results for ") + self.filename - self.set_title(title) - self.clear() - self.data = {} - - # Populating tree - results = ( - ("Convention", self.results["C:"]), - ("Refactor", self.results["R:"]), - ("Warning", self.results["W:"]), - ("Error", self.results["E:"]), - ) - - for category, messages in results: - title_item = CategoryItem(self, category, len(messages)) - if not messages: - title_item.setDisabled(True) - - modules = {} - for message_data in messages: - # If message data is legacy version without message_name - if len(message_data) == 4: - message_data = tuple(list(message_data) + [None]) - - module, lineno, message, msg_id, message_name = message_data - - basename = osp.splitext(osp.basename(self.filename))[0] - if not module.startswith(basename): - # Pylint bug - i_base = module.find(basename) - module = module[i_base:] - - dirname = osp.dirname(self.filename) - if module.startswith(".") or module == basename: - modname = osp.join(dirname, module) - else: - modname = osp.join(dirname, *module.split(".")) - - if osp.isdir(modname): - modname = osp.join(modname, "__init__") - - for ext in (".py", ".pyw"): - if osp.isfile(modname+ext): - modname = modname + ext - break - - if osp.isdir(self.filename): - parent = modules.get(modname) - if parent is None: - item = QTreeWidgetItem(title_item, [module], - QTreeWidgetItem.Type) - item.setIcon(0, ima.icon("python")) - modules[modname] = item - parent = item - else: - parent = title_item - - if len(msg_id) > 1: - if not message_name: - message_string = "{msg_id} " - else: - message_string = "{msg_id} ({message_name}) " - - message_string += "line {lineno}: {message}" - message_string = message_string.format( - msg_id=msg_id, message_name=message_name, - lineno=lineno, message=message) - msg_item = QTreeWidgetItem( - parent, [message_string], QTreeWidgetItem.Type) - msg_item.setIcon(0, ima.icon("arrow")) - self.data[id(msg_item)] = (modname, lineno) - - -class PylintWidget(PluginMainWidget): - """ - Pylint widget. - """ - ENABLE_SPINNER = True - - DATAPATH = get_conf_path("pylint.results") - VERSION = "1.1.0" - - # --- Signals - sig_edit_goto_requested = Signal(str, int, str) - """ - This signal will request to open a file in a given row and column - using a code editor. - - Parameters - ---------- - path: str - Path to file. - row: int - Cursor starting row position. - word: str - Word to select on given row. - """ - - sig_start_analysis_requested = Signal() - """ - This signal will request the plugin to start the analysis. This is to be - able to interact with other plugins, which can only be done at the plugin - level. - """ - - def __init__(self, name=None, plugin=None, parent=None): - super().__init__(name, plugin, parent) - - # Attributes - self._process = None - self.output = None - self.error_output = None - self.filename = None - self.rdata = [] - self.curr_filenames = self.get_conf("history_filenames") - self.code_analysis_action = None - self.browse_action = None - - # Widgets - self.filecombo = PythonModulesComboBox( - self, id_=PylintWidgetToolbarItems.FileComboBox) - - self.ratelabel = QLabel(self) - self.ratelabel.ID = PylintWidgetToolbarItems.RateLabel - - self.datelabel = QLabel(self) - self.datelabel.ID = PylintWidgetToolbarItems.DateLabel - - self.treewidget = ResultsTree(self) - - if osp.isfile(self.DATAPATH): - try: - with open(self.DATAPATH, "rb") as fh: - data = pickle.loads(fh.read()) - - if data[0] == self.VERSION: - self.rdata = data[1:] - except (EOFError, ImportError): - pass - - # Widget setup - self.filecombo.setInsertPolicy(self.filecombo.InsertAtTop) - for fname in self.curr_filenames[::-1]: - self.set_filename(fname) - - # Layout - layout = QVBoxLayout() - layout.addWidget(self.treewidget) - self.setLayout(layout) - - # Signals - self.filecombo.valid.connect(self._check_new_file) - self.treewidget.sig_edit_goto_requested.connect( - self.sig_edit_goto_requested) - - def on_close(self): - self.stop_code_analysis() - - # --- Private API - # ------------------------------------------------------------------------ - @Slot() - def _start(self): - """Start the code analysis.""" - self.start_spinner() - self.output = "" - self.error_output = "" - self._process = process = QProcess(self) - - process.setProcessChannelMode(QProcess.SeparateChannels) - process.setWorkingDirectory(getcwd_or_home()) - process.readyReadStandardOutput.connect(self._read_output) - process.readyReadStandardError.connect( - lambda: self._read_output(error=True)) - process.finished.connect( - lambda ec, es=QProcess.ExitStatus: self._finished(ec, es)) - - command_args = self.get_command(self.get_filename()) - processEnvironment = QProcessEnvironment() - processEnvironment.insert("PYTHONIOENCODING", "utf8") - - # Needed due to changes in Pylint 2.14.0 - # See spyder-ide/spyder#18175 - if os.name == 'nt': - home_dir = get_home_dir() - user_profile = os.environ.get("USERPROFILE", home_dir) - processEnvironment.insert("USERPROFILE", user_profile) - - # resolve spyder-ide/spyder#14262 - if running_in_mac_app(): - pyhome = os.environ.get("PYTHONHOME") - processEnvironment.insert("PYTHONHOME", pyhome) - - process.setProcessEnvironment(processEnvironment) - process.start(sys.executable, command_args) - running = process.waitForStarted() - if not running: - self.stop_spinner() - QMessageBox.critical( - self, - _("Error"), - _("Process failed to start"), - ) - - def _read_output(self, error=False): - process = self._process - if error: - process.setReadChannel(QProcess.StandardError) - else: - process.setReadChannel(QProcess.StandardOutput) - - qba = QByteArray() - while process.bytesAvailable(): - if error: - qba += process.readAllStandardError() - else: - qba += process.readAllStandardOutput() - - text = str(qba.data(), "utf-8") - if error: - self.error_output += text - else: - self.output += text - - self.update_actions() - - def _finished(self, exit_code, exit_status): - if not self.output: - self.stop_spinner() - if self.error_output: - QMessageBox.critical( - self, - _("Error"), - self.error_output, - ) - print("pylint error:\n\n" + self.error_output, file=sys.stderr) - return - - filename = self.get_filename() - rate, previous, results = self.parse_output(self.output) - self._save_history() - self.set_data(filename, (time.localtime(), rate, previous, results)) - self.output = self.error_output + self.output - self.show_data(justanalyzed=True) - self.update_actions() - self.stop_spinner() - - def _check_new_file(self): - fname = self.get_filename() - if fname != self.filename: - self.filename = fname - self.show_data() - - def _is_running(self): - process = self._process - return process is not None and process.state() == QProcess.Running - - def _kill_process(self): - self._process.close() - self._process.waitForFinished(1000) - self.stop_spinner() - - def _update_combobox_history(self): - """Change the number of files listed in the history combobox.""" - max_entries = self.get_conf("max_entries") - if self.filecombo.count() > max_entries: - num_elements = self.filecombo.count() - diff = num_elements - max_entries - for __ in range(diff): - num_elements = self.filecombo.count() - self.filecombo.removeItem(num_elements - 1) - self.filecombo.selected() - else: - num_elements = self.filecombo.count() - diff = max_entries - num_elements - for i in range(num_elements, num_elements + diff): - if i >= len(self.curr_filenames): - break - act_filename = self.curr_filenames[i] - self.filecombo.insertItem(i, act_filename) - - def _save_history(self): - """Save the current history filenames.""" - if self.parent: - list_save_files = [] - for fname in self.curr_filenames: - if _("untitled") not in fname: - filename = osp.normpath(fname) - list_save_files.append(fname) - - self.curr_filenames = list_save_files[:MAX_HISTORY_ENTRIES] - self.set_conf("history_filenames", self.curr_filenames) - else: - self.curr_filenames = [] - - # --- PluginMainWidget API - # ------------------------------------------------------------------------ - def get_title(self): - return _("Code Analysis") - - def get_focus_widget(self): - return self.treewidget - - def setup(self): - change_history_depth_action = self.create_action( - PylintWidgetActions.ChangeHistory, - text=_("History..."), - tip=_("Set history maximum entries"), - icon=self.create_icon("history"), - triggered=self.change_history_depth, - ) - self.code_analysis_action = self.create_action( - PylintWidgetActions.RunCodeAnalysis, - text=_("Run code analysis"), - tip=_("Run code analysis"), - icon=self.create_icon("run"), - triggered=lambda: self.sig_start_analysis_requested.emit(), - ) - self.browse_action = self.create_action( - PylintWidgetActions.BrowseFile, - text=_("Select Python file"), - tip=_("Select Python file"), - icon=self.create_icon("fileopen"), - triggered=self.select_file, - ) - self.log_action = self.create_action( - PylintWidgetActions.ShowLog, - text=_("Output"), - tip=_("Complete output"), - icon=self.create_icon("log"), - triggered=self.show_log, - ) - - options_menu = self.get_options_menu() - self.add_item_to_menu( - self.treewidget.get_action( - OneColumnTreeActions.CollapseAllAction), - menu=options_menu, - section=PylintWidgetOptionsMenuSections.Global, - ) - self.add_item_to_menu( - self.treewidget.get_action( - OneColumnTreeActions.ExpandAllAction), - menu=options_menu, - section=PylintWidgetOptionsMenuSections.Global, - ) - self.add_item_to_menu( - self.treewidget.get_action( - OneColumnTreeActions.CollapseSelectionAction), - menu=options_menu, - section=PylintWidgetOptionsMenuSections.Section, - ) - self.add_item_to_menu( - self.treewidget.get_action( - OneColumnTreeActions.ExpandSelectionAction), - menu=options_menu, - section=PylintWidgetOptionsMenuSections.Section, - ) - self.add_item_to_menu( - change_history_depth_action, - menu=options_menu, - section=PylintWidgetOptionsMenuSections.History, - ) - - # Update OneColumnTree contextual menu - self.add_item_to_menu( - change_history_depth_action, - menu=self.treewidget.menu, - section=PylintWidgetOptionsMenuSections.History, - ) - self.treewidget.restore_action.setVisible(False) - - toolbar = self.get_main_toolbar() - for item in [self.filecombo, self.browse_action, - self.code_analysis_action]: - self.add_item_to_toolbar( - item, - toolbar, - section=PylintWidgetMainToolbarSections.Main, - ) - - secondary_toolbar = self.create_toolbar("secondary") - for item in [self.ratelabel, - self.create_stretcher( - id_=PylintWidgetToolbarItems.Stretcher1), - self.datelabel, - self.create_stretcher( - id_=PylintWidgetToolbarItems.Stretcher2), - self.log_action]: - self.add_item_to_toolbar( - item, - secondary_toolbar, - section=PylintWidgetMainToolbarSections.Main, - ) - - self.show_data() - - if self.rdata: - self.remove_obsolete_items() - self.filecombo.insertItems(0, self.get_filenames()) - self.code_analysis_action.setEnabled(self.filecombo.is_valid()) - else: - self.code_analysis_action.setEnabled(False) - - # Signals - self.filecombo.valid.connect(self.code_analysis_action.setEnabled) - - @on_conf_change(option=['max_entries', 'history_filenames']) - def on_conf_update(self, option, value): - if option == "max_entries": - self._update_combobox_history() - elif option == "history_filenames": - self.curr_filenames = value - self._update_combobox_history() - - def update_actions(self): - if self._is_running(): - self.code_analysis_action.setIcon(self.create_icon("stop")) - else: - self.code_analysis_action.setIcon(self.create_icon("run")) - - self.remove_obsolete_items() - - def on_close(self): - self.stop_code_analysis() - - # --- Public API - # ------------------------------------------------------------------------ - @Slot() - @Slot(int) - def change_history_depth(self, value=None): - """ - Set history maximum entries. - - Parameters - ---------- - value: int or None, optional - The valur to set the maximum history depth. If no value is - provided, an input dialog will be launched. Default is None. - """ - if value is None: - dialog = QInputDialog(self) - - # Set dialog properties - dialog.setModal(False) - dialog.setWindowTitle(_("History")) - dialog.setLabelText(_("Maximum entries")) - dialog.setInputMode(QInputDialog.IntInput) - dialog.setIntRange(MIN_HISTORY_ENTRIES, MAX_HISTORY_ENTRIES) - dialog.setIntStep(1) - dialog.setIntValue(self.get_conf("max_entries")) - - # Connect slot - dialog.intValueSelected.connect( - lambda value: self.set_conf("max_entries", value)) - - dialog.show() - else: - self.set_conf("max_entries", value) - - def get_filename(self): - """ - Get current filename in combobox. - """ - return str(self.filecombo.currentText()) - - @Slot(str) - def set_filename(self, filename): - """ - Set current filename in combobox. - """ - if self._is_running(): - self._kill_process() - - filename = str(filename) - filename = osp.normpath(filename) # Normalize path for Windows - - # Don't try to reload saved analysis for filename, if filename - # is the one currently displayed. - # Fixes spyder-ide/spyder#13347 - if self.get_filename() == filename: - return - - index, _data = self.get_data(filename) - - if filename not in self.curr_filenames: - self.filecombo.insertItem(0, filename) - self.curr_filenames.insert(0, filename) - self.filecombo.setCurrentIndex(0) - else: - try: - index = self.filecombo.findText(filename) - self.filecombo.removeItem(index) - self.curr_filenames.pop(index) - except IndexError: - self.curr_filenames.remove(filename) - self.filecombo.insertItem(0, filename) - self.curr_filenames.insert(0, filename) - self.filecombo.setCurrentIndex(0) - - num_elements = self.filecombo.count() - if num_elements > self.get_conf("max_entries"): - self.filecombo.removeItem(num_elements - 1) - - self.filecombo.selected() - - def start_code_analysis(self, filename=None): - """ - Perform code analysis for given `filename`. - - If `filename` is None default to current filename in combobox. - - If this method is called while still running it will stop the code - analysis. - """ - if self._is_running(): - self._kill_process() - else: - if filename is not None: - self.set_filename(filename) - - if self.filecombo.is_valid(): - self._start() - - self.update_actions() - - def stop_code_analysis(self): - """ - Stop the code analysis process. - """ - if self._is_running(): - self._kill_process() - - def remove_obsolete_items(self): - """ - Removing obsolete items. - """ - self.rdata = [(filename, data) for filename, data in self.rdata - if is_module_or_package(filename)] - - def get_filenames(self): - """ - Return all filenames for which there is data available. - """ - return [filename for filename, _data in self.rdata] - - def get_data(self, filename): - """ - Get and load code analysis data for given `filename`. - """ - filename = osp.abspath(filename) - for index, (fname, data) in enumerate(self.rdata): - if fname == filename: - return index, data - else: - return None, None - - def set_data(self, filename, data): - """ - Set and save code analysis `data` for given `filename`. - """ - filename = osp.abspath(filename) - index, _data = self.get_data(filename) - if index is not None: - self.rdata.pop(index) - - self.rdata.insert(0, (filename, data)) - - while len(self.rdata) > self.get_conf("max_entries"): - self.rdata.pop(-1) - - with open(self.DATAPATH, "wb") as fh: - pickle.dump([self.VERSION] + self.rdata, fh, 2) - - def show_data(self, justanalyzed=False): - """ - Show data in treewidget. - """ - text_color = MAIN_TEXT_COLOR - prevrate_color = MAIN_PREVRATE_COLOR - - if not justanalyzed: - self.output = None - - self.log_action.setEnabled(self.output is not None - and len(self.output) > 0) - - if self._is_running(): - self._kill_process() - - filename = self.get_filename() - if not filename: - return - - _index, data = self.get_data(filename) - if data is None: - text = _("Source code has not been rated yet.") - self.treewidget.clear_results() - date_text = "" - else: - datetime, rate, previous_rate, results = data - if rate is None: - text = _("Analysis did not succeed " - "(see output for more details).") - self.treewidget.clear_results() - date_text = "" - else: - text_style = "%s " - rate_style = "%s" - prevrate_style = "%s" - color = DANGER_COLOR - if float(rate) > 5.: - color = SUCCESS_COLOR - elif float(rate) > 3.: - color = WARNING_COLOR - - text = _("Global evaluation:") - text = ((text_style % (text_color, text)) - + (rate_style % (color, ("%s/10" % rate)))) - if previous_rate: - text_prun = _("previous run:") - text_prun = " (%s %s/10)" % (text_prun, previous_rate) - text += prevrate_style % (prevrate_color, text_prun) - - self.treewidget.set_results(filename, results) - date = time.strftime("%Y-%m-%d %H:%M:%S", datetime) - date_text = text_style % (text_color, date) - - self.ratelabel.setText(text) - self.datelabel.setText(date_text) - - @Slot() - def show_log(self): - """ - Show output log dialog. - """ - if self.output: - output_dialog = TextEditor( - self.output, - title=_("Code analysis output"), - parent=self, - readonly=True - ) - output_dialog.resize(700, 500) - output_dialog.exec_() - - # --- Python Specific - # ------------------------------------------------------------------------ - def get_pylintrc_path(self, filename): - """ - Get the path to the most proximate pylintrc config to the file. - """ - search_paths = [ - # File"s directory - osp.dirname(filename), - # Working directory - getcwd_or_home(), - # Project directory - self.get_conf("project_dir"), - # Home directory - osp.expanduser("~"), - ] - - return get_pylintrc_path(search_paths=search_paths) - - @Slot() - def select_file(self, filename=None): - """ - Select filename using a open file dialog and set as current filename. - - If `filename` is provided, the dialog is not used. - """ - if filename is None: - self.sig_redirect_stdio_requested.emit(False) - filename, _selfilter = getopenfilename( - self, - _("Select Python file"), - getcwd_or_home(), - _("Python files") + " (*.py ; *.pyw)", - ) - self.sig_redirect_stdio_requested.emit(True) - - if filename: - self.set_filename(filename) - self.start_code_analysis() - - def get_command(self, filename): - """ - Return command to use to run code analysis on given filename - """ - command_args = [] - if PYLINT_VER is not None: - command_args = [ - "-m", - "pylint", - "--output-format=text", - "--msg-template=" - '{msg_id}:{symbol}:{line:3d},{column}: {msg}"', - ] - - pylintrc_path = self.get_pylintrc_path(filename=filename) - if pylintrc_path is not None: - command_args += ["--rcfile={}".format(pylintrc_path)] - - command_args.append(filename) - return command_args - - def parse_output(self, output): - """ - Parse output and return current revious rate and results. - """ - # Convention, Refactor, Warning, Error - results = {"C:": [], "R:": [], "W:": [], "E:": []} - txt_module = "************* Module " - - module = "" # Should not be needed - just in case something goes wrong - for line in output.splitlines(): - if line.startswith(txt_module): - # New module - module = line[len(txt_module):] - continue - # Supporting option include-ids: ("R3873:" instead of "R:") - if not re.match(r"^[CRWE]+([0-9]{4})?:", line): - continue - - items = {} - idx_0 = 0 - idx_1 = 0 - key_names = ["msg_id", "message_name", "line_nb", "message"] - for key_idx, key_name in enumerate(key_names): - if key_idx == len(key_names) - 1: - idx_1 = len(line) - else: - idx_1 = line.find(":", idx_0) - - if idx_1 < 0: - break - - item = line[(idx_0):idx_1] - if not item: - break - - if key_name == "line_nb": - item = int(item.split(",")[0]) - - items[key_name] = item - idx_0 = idx_1 + 1 - else: - pylint_item = (module, items["line_nb"], items["message"], - items["msg_id"], items["message_name"]) - results[line[0] + ":"].append(pylint_item) - - # Rate - rate = None - txt_rate = "Your code has been rated at " - i_rate = output.find(txt_rate) - if i_rate > 0: - i_rate_end = output.find("/10", i_rate) - if i_rate_end > 0: - rate = output[i_rate+len(txt_rate):i_rate_end] - - # Previous run - previous = "" - if rate is not None: - txt_prun = "previous run: " - i_prun = output.find(txt_prun, i_rate_end) - if i_prun > 0: - i_prun_end = output.find("/10", i_prun) - previous = output[i_prun+len(txt_prun):i_prun_end] - - return rate, previous, results - - -# ============================================================================= -# Tests -# ============================================================================= -def test(): - """Run pylint widget test""" - from spyder.utils.qthelpers import qapplication - from unittest.mock import MagicMock - - plugin_mock = MagicMock() - plugin_mock.CONF_SECTION = 'pylint' - - app = qapplication(test_time=20) - widget = PylintWidget(name="pylint", plugin=plugin_mock) - widget._setup() - widget.setup() - widget.resize(640, 480) - widget.show() - widget.start_code_analysis(filename=__file__) - sys.exit(app.exec_()) - - -if __name__ == "__main__": - test() +# -*- coding: utf-8 -*- +# +# Copyright © Spyder Project Contributors +# Licensed under the terms of the MIT License +# (see spyder/__init__.py for details) + +"""Pylint widget.""" + +# pylint: disable=C0103 +# pylint: disable=R0903 +# pylint: disable=R0911 +# pylint: disable=R0201 + +# Standard library imports +import os +import os.path as osp +import pickle +import re +import sys +import time + +# Third party imports +import pylint +from qtpy.compat import getopenfilename +from qtpy.QtCore import (QByteArray, QProcess, QProcessEnvironment, Signal, + Slot) +from qtpy.QtWidgets import (QInputDialog, QLabel, QMessageBox, QTreeWidgetItem, + QVBoxLayout) + +# Local imports +from spyder.api.config.decorators import on_conf_change +from spyder.api.translations import get_translation +from spyder.api.widgets.main_widget import PluginMainWidget +from spyder.config.base import get_conf_path, running_in_mac_app +from spyder.plugins.pylint.utils import get_pylintrc_path +from spyder.plugins.variableexplorer.widgets.texteditor import TextEditor +from spyder.utils.icon_manager import ima +from spyder.utils.misc import getcwd_or_home, get_home_dir +from spyder.utils.palette import QStylePalette, SpyderPalette +from spyder.widgets.comboboxes import (PythonModulesComboBox, + is_module_or_package) +from spyder.widgets.onecolumntree import OneColumnTree, OneColumnTreeActions + +# Localization +_ = get_translation("spyder") + + +# --- Constants +# ---------------------------------------------------------------------------- +PYLINT_VER = pylint.__version__ +MIN_HISTORY_ENTRIES = 5 +MAX_HISTORY_ENTRIES = 100 +DANGER_COLOR = SpyderPalette.COLOR_ERROR_1 +WARNING_COLOR = SpyderPalette.COLOR_WARN_1 +SUCCESS_COLOR = SpyderPalette.COLOR_SUCCESS_1 + + +# TODO: There should be some palette from the appearance plugin so this +# is easier to use +MAIN_TEXT_COLOR = QStylePalette.COLOR_TEXT_1 +MAIN_PREVRATE_COLOR = QStylePalette.COLOR_TEXT_1 + + + +class PylintWidgetActions: + ChangeHistory = "change_history_depth_action" + RunCodeAnalysis = "run_analysis_action" + BrowseFile = "browse_action" + ShowLog = "log_action" + + +class PylintWidgetOptionsMenuSections: + Global = "global_section" + Section = "section_section" + History = "history_section" + + +class PylintWidgetMainToolbarSections: + Main = "main_section" + + +class PylintWidgetToolbarItems: + FileComboBox = 'file_combo' + RateLabel = 'rate_label' + DateLabel = 'date_label' + Stretcher1 = 'stretcher_1' + Stretcher2 = 'stretcher_2' + + +# ---- Items +class CategoryItem(QTreeWidgetItem): + """ + Category item for results. + + Notes + ----- + Possible categories are Convention, Refactor, Warning and Error. + """ + + CATEGORIES = { + "Convention": { + 'translation_string': _("Convention"), + 'icon': ima.icon("convention") + }, + "Refactor": { + 'translation_string': _("Refactor"), + 'icon': ima.icon("refactor") + }, + "Warning": { + 'translation_string': _("Warning"), + 'icon': ima.icon("warning") + }, + "Error": { + 'translation_string': _("Error"), + 'icon': ima.icon("error") + } + } + + def __init__(self, parent, category, number_of_messages): + # Messages string to append to category. + if number_of_messages > 1 or number_of_messages == 0: + messages = _('messages') + else: + messages = _('message') + + # Category title. + title = self.CATEGORIES[category]['translation_string'] + title += f" ({number_of_messages} {messages})" + + super().__init__(parent, [title], QTreeWidgetItem.Type) + + # Set icon + icon = self.CATEGORIES[category]['icon'] + self.setIcon(0, icon) + + +# ---- Widgets +# ---------------------------------------------------------------------------- +# TODO: display results on 3 columns instead of 1: msg_id, lineno, message +class ResultsTree(OneColumnTree): + + sig_edit_goto_requested = Signal(str, int, str) + """ + This signal will request to open a file in a given row and column + using a code editor. + + Parameters + ---------- + path: str + Path to file. + row: int + Cursor starting row position. + word: str + Word to select on given row. + """ + + def __init__(self, parent): + super().__init__(parent) + self.filename = None + self.results = None + self.data = None + self.set_title("") + + def activated(self, item): + """Double-click event""" + data = self.data.get(id(item)) + if data is not None: + fname, lineno = data + self.sig_edit_goto_requested.emit(fname, lineno, "") + + def clicked(self, item): + """Click event.""" + if isinstance(item, CategoryItem): + if item.isExpanded(): + self.collapseItem(item) + else: + self.expandItem(item) + else: + self.activated(item) + + def clear_results(self): + self.clear() + self.set_title("") + + def set_results(self, filename, results): + self.filename = filename + self.results = results + self.refresh() + + def refresh(self): + title = _("Results for ") + self.filename + self.set_title(title) + self.clear() + self.data = {} + + # Populating tree + results = ( + ("Convention", self.results["C:"]), + ("Refactor", self.results["R:"]), + ("Warning", self.results["W:"]), + ("Error", self.results["E:"]), + ) + + for category, messages in results: + title_item = CategoryItem(self, category, len(messages)) + if not messages: + title_item.setDisabled(True) + + modules = {} + for message_data in messages: + # If message data is legacy version without message_name + if len(message_data) == 4: + message_data = tuple(list(message_data) + [None]) + + module, lineno, message, msg_id, message_name = message_data + + basename = osp.splitext(osp.basename(self.filename))[0] + if not module.startswith(basename): + # Pylint bug + i_base = module.find(basename) + module = module[i_base:] + + dirname = osp.dirname(self.filename) + if module.startswith(".") or module == basename: + modname = osp.join(dirname, module) + else: + modname = osp.join(dirname, *module.split(".")) + + if osp.isdir(modname): + modname = osp.join(modname, "__init__") + + for ext in (".py", ".pyw"): + if osp.isfile(modname+ext): + modname = modname + ext + break + + if osp.isdir(self.filename): + parent = modules.get(modname) + if parent is None: + item = QTreeWidgetItem(title_item, [module], + QTreeWidgetItem.Type) + item.setIcon(0, ima.icon("python")) + modules[modname] = item + parent = item + else: + parent = title_item + + if len(msg_id) > 1: + if not message_name: + message_string = "{msg_id} " + else: + message_string = "{msg_id} ({message_name}) " + + message_string += "line {lineno}: {message}" + message_string = message_string.format( + msg_id=msg_id, message_name=message_name, + lineno=lineno, message=message) + msg_item = QTreeWidgetItem( + parent, [message_string], QTreeWidgetItem.Type) + msg_item.setIcon(0, ima.icon("arrow")) + self.data[id(msg_item)] = (modname, lineno) + + +class PylintWidget(PluginMainWidget): + """ + Pylint widget. + """ + ENABLE_SPINNER = True + + DATAPATH = get_conf_path("pylint.results") + VERSION = "1.1.0" + + # --- Signals + sig_edit_goto_requested = Signal(str, int, str) + """ + This signal will request to open a file in a given row and column + using a code editor. + + Parameters + ---------- + path: str + Path to file. + row: int + Cursor starting row position. + word: str + Word to select on given row. + """ + + sig_start_analysis_requested = Signal() + """ + This signal will request the plugin to start the analysis. This is to be + able to interact with other plugins, which can only be done at the plugin + level. + """ + + def __init__(self, name=None, plugin=None, parent=None): + super().__init__(name, plugin, parent) + + # Attributes + self._process = None + self.output = None + self.error_output = None + self.filename = None + self.rdata = [] + self.curr_filenames = self.get_conf("history_filenames") + self.code_analysis_action = None + self.browse_action = None + + # Widgets + self.filecombo = PythonModulesComboBox( + self, id_=PylintWidgetToolbarItems.FileComboBox) + + self.ratelabel = QLabel(self) + self.ratelabel.ID = PylintWidgetToolbarItems.RateLabel + + self.datelabel = QLabel(self) + self.datelabel.ID = PylintWidgetToolbarItems.DateLabel + + self.treewidget = ResultsTree(self) + + if osp.isfile(self.DATAPATH): + try: + with open(self.DATAPATH, "rb") as fh: + data = pickle.loads(fh.read()) + + if data[0] == self.VERSION: + self.rdata = data[1:] + except (EOFError, ImportError): + pass + + # Widget setup + self.filecombo.setInsertPolicy(self.filecombo.InsertAtTop) + for fname in self.curr_filenames[::-1]: + self.set_filename(fname) + + # Layout + layout = QVBoxLayout() + layout.addWidget(self.treewidget) + self.setLayout(layout) + + # Signals + self.filecombo.valid.connect(self._check_new_file) + self.treewidget.sig_edit_goto_requested.connect( + self.sig_edit_goto_requested) + + def on_close(self): + self.stop_code_analysis() + + # --- Private API + # ------------------------------------------------------------------------ + @Slot() + def _start(self): + """Start the code analysis.""" + self.start_spinner() + self.output = "" + self.error_output = "" + self._process = process = QProcess(self) + + process.setProcessChannelMode(QProcess.SeparateChannels) + process.setWorkingDirectory(getcwd_or_home()) + process.readyReadStandardOutput.connect(self._read_output) + process.readyReadStandardError.connect( + lambda: self._read_output(error=True)) + process.finished.connect( + lambda ec, es=QProcess.ExitStatus: self._finished(ec, es)) + + command_args = self.get_command(self.get_filename()) + processEnvironment = QProcessEnvironment() + processEnvironment.insert("PYTHONIOENCODING", "utf8") + + # Needed due to changes in Pylint 2.14.0 + # See spyder-ide/spyder#18175 + if os.name == 'nt': + home_dir = get_home_dir() + user_profile = os.environ.get("USERPROFILE", home_dir) + processEnvironment.insert("USERPROFILE", user_profile) + + # resolve spyder-ide/spyder#14262 + if running_in_mac_app(): + pyhome = os.environ.get("PYTHONHOME") + processEnvironment.insert("PYTHONHOME", pyhome) + + process.setProcessEnvironment(processEnvironment) + process.start(sys.executable, command_args) + running = process.waitForStarted() + if not running: + self.stop_spinner() + QMessageBox.critical( + self, + _("Error"), + _("Process failed to start"), + ) + + def _read_output(self, error=False): + process = self._process + if error: + process.setReadChannel(QProcess.StandardError) + else: + process.setReadChannel(QProcess.StandardOutput) + + qba = QByteArray() + while process.bytesAvailable(): + if error: + qba += process.readAllStandardError() + else: + qba += process.readAllStandardOutput() + + text = str(qba.data(), "utf-8") + if error: + self.error_output += text + else: + self.output += text + + self.update_actions() + + def _finished(self, exit_code, exit_status): + if not self.output: + self.stop_spinner() + if self.error_output: + QMessageBox.critical( + self, + _("Error"), + self.error_output, + ) + print("pylint error:\n\n" + self.error_output, file=sys.stderr) + return + + filename = self.get_filename() + rate, previous, results = self.parse_output(self.output) + self._save_history() + self.set_data(filename, (time.localtime(), rate, previous, results)) + self.output = self.error_output + self.output + self.show_data(justanalyzed=True) + self.update_actions() + self.stop_spinner() + + def _check_new_file(self): + fname = self.get_filename() + if fname != self.filename: + self.filename = fname + self.show_data() + + def _is_running(self): + process = self._process + return process is not None and process.state() == QProcess.Running + + def _kill_process(self): + self._process.close() + self._process.waitForFinished(1000) + self.stop_spinner() + + def _update_combobox_history(self): + """Change the number of files listed in the history combobox.""" + max_entries = self.get_conf("max_entries") + if self.filecombo.count() > max_entries: + num_elements = self.filecombo.count() + diff = num_elements - max_entries + for __ in range(diff): + num_elements = self.filecombo.count() + self.filecombo.removeItem(num_elements - 1) + self.filecombo.selected() + else: + num_elements = self.filecombo.count() + diff = max_entries - num_elements + for i in range(num_elements, num_elements + diff): + if i >= len(self.curr_filenames): + break + act_filename = self.curr_filenames[i] + self.filecombo.insertItem(i, act_filename) + + def _save_history(self): + """Save the current history filenames.""" + if self.parent: + list_save_files = [] + for fname in self.curr_filenames: + if _("untitled") not in fname: + filename = osp.normpath(fname) + list_save_files.append(fname) + + self.curr_filenames = list_save_files[:MAX_HISTORY_ENTRIES] + self.set_conf("history_filenames", self.curr_filenames) + else: + self.curr_filenames = [] + + # --- PluginMainWidget API + # ------------------------------------------------------------------------ + def get_title(self): + return _("Code Analysis") + + def get_focus_widget(self): + return self.treewidget + + def setup(self): + change_history_depth_action = self.create_action( + PylintWidgetActions.ChangeHistory, + text=_("History..."), + tip=_("Set history maximum entries"), + icon=self.create_icon("history"), + triggered=self.change_history_depth, + ) + self.code_analysis_action = self.create_action( + PylintWidgetActions.RunCodeAnalysis, + text=_("Run code analysis"), + tip=_("Run code analysis"), + icon=self.create_icon("run"), + triggered=lambda: self.sig_start_analysis_requested.emit(), + ) + self.browse_action = self.create_action( + PylintWidgetActions.BrowseFile, + text=_("Select Python file"), + tip=_("Select Python file"), + icon=self.create_icon("fileopen"), + triggered=self.select_file, + ) + self.log_action = self.create_action( + PylintWidgetActions.ShowLog, + text=_("Output"), + tip=_("Complete output"), + icon=self.create_icon("log"), + triggered=self.show_log, + ) + + options_menu = self.get_options_menu() + self.add_item_to_menu( + self.treewidget.get_action( + OneColumnTreeActions.CollapseAllAction), + menu=options_menu, + section=PylintWidgetOptionsMenuSections.Global, + ) + self.add_item_to_menu( + self.treewidget.get_action( + OneColumnTreeActions.ExpandAllAction), + menu=options_menu, + section=PylintWidgetOptionsMenuSections.Global, + ) + self.add_item_to_menu( + self.treewidget.get_action( + OneColumnTreeActions.CollapseSelectionAction), + menu=options_menu, + section=PylintWidgetOptionsMenuSections.Section, + ) + self.add_item_to_menu( + self.treewidget.get_action( + OneColumnTreeActions.ExpandSelectionAction), + menu=options_menu, + section=PylintWidgetOptionsMenuSections.Section, + ) + self.add_item_to_menu( + change_history_depth_action, + menu=options_menu, + section=PylintWidgetOptionsMenuSections.History, + ) + + # Update OneColumnTree contextual menu + self.add_item_to_menu( + change_history_depth_action, + menu=self.treewidget.menu, + section=PylintWidgetOptionsMenuSections.History, + ) + self.treewidget.restore_action.setVisible(False) + + toolbar = self.get_main_toolbar() + for item in [self.filecombo, self.browse_action, + self.code_analysis_action]: + self.add_item_to_toolbar( + item, + toolbar, + section=PylintWidgetMainToolbarSections.Main, + ) + + secondary_toolbar = self.create_toolbar("secondary") + for item in [self.ratelabel, + self.create_stretcher( + id_=PylintWidgetToolbarItems.Stretcher1), + self.datelabel, + self.create_stretcher( + id_=PylintWidgetToolbarItems.Stretcher2), + self.log_action]: + self.add_item_to_toolbar( + item, + secondary_toolbar, + section=PylintWidgetMainToolbarSections.Main, + ) + + self.show_data() + + if self.rdata: + self.remove_obsolete_items() + self.filecombo.insertItems(0, self.get_filenames()) + self.code_analysis_action.setEnabled(self.filecombo.is_valid()) + else: + self.code_analysis_action.setEnabled(False) + + # Signals + self.filecombo.valid.connect(self.code_analysis_action.setEnabled) + + @on_conf_change(option=['max_entries', 'history_filenames']) + def on_conf_update(self, option, value): + if option == "max_entries": + self._update_combobox_history() + elif option == "history_filenames": + self.curr_filenames = value + self._update_combobox_history() + + def update_actions(self): + if self._is_running(): + self.code_analysis_action.setIcon(self.create_icon("stop")) + else: + self.code_analysis_action.setIcon(self.create_icon("run")) + + self.remove_obsolete_items() + + def on_close(self): + self.stop_code_analysis() + + # --- Public API + # ------------------------------------------------------------------------ + @Slot() + @Slot(int) + def change_history_depth(self, value=None): + """ + Set history maximum entries. + + Parameters + ---------- + value: int or None, optional + The valur to set the maximum history depth. If no value is + provided, an input dialog will be launched. Default is None. + """ + if value is None: + dialog = QInputDialog(self) + + # Set dialog properties + dialog.setModal(False) + dialog.setWindowTitle(_("History")) + dialog.setLabelText(_("Maximum entries")) + dialog.setInputMode(QInputDialog.IntInput) + dialog.setIntRange(MIN_HISTORY_ENTRIES, MAX_HISTORY_ENTRIES) + dialog.setIntStep(1) + dialog.setIntValue(self.get_conf("max_entries")) + + # Connect slot + dialog.intValueSelected.connect( + lambda value: self.set_conf("max_entries", value)) + + dialog.show() + else: + self.set_conf("max_entries", value) + + def get_filename(self): + """ + Get current filename in combobox. + """ + return str(self.filecombo.currentText()) + + @Slot(str) + def set_filename(self, filename): + """ + Set current filename in combobox. + """ + if self._is_running(): + self._kill_process() + + filename = str(filename) + filename = osp.normpath(filename) # Normalize path for Windows + + # Don't try to reload saved analysis for filename, if filename + # is the one currently displayed. + # Fixes spyder-ide/spyder#13347 + if self.get_filename() == filename: + return + + index, _data = self.get_data(filename) + + if filename not in self.curr_filenames: + self.filecombo.insertItem(0, filename) + self.curr_filenames.insert(0, filename) + self.filecombo.setCurrentIndex(0) + else: + try: + index = self.filecombo.findText(filename) + self.filecombo.removeItem(index) + self.curr_filenames.pop(index) + except IndexError: + self.curr_filenames.remove(filename) + self.filecombo.insertItem(0, filename) + self.curr_filenames.insert(0, filename) + self.filecombo.setCurrentIndex(0) + + num_elements = self.filecombo.count() + if num_elements > self.get_conf("max_entries"): + self.filecombo.removeItem(num_elements - 1) + + self.filecombo.selected() + + def start_code_analysis(self, filename=None): + """ + Perform code analysis for given `filename`. + + If `filename` is None default to current filename in combobox. + + If this method is called while still running it will stop the code + analysis. + """ + if self._is_running(): + self._kill_process() + else: + if filename is not None: + self.set_filename(filename) + + if self.filecombo.is_valid(): + self._start() + + self.update_actions() + + def stop_code_analysis(self): + """ + Stop the code analysis process. + """ + if self._is_running(): + self._kill_process() + + def remove_obsolete_items(self): + """ + Removing obsolete items. + """ + self.rdata = [(filename, data) for filename, data in self.rdata + if is_module_or_package(filename)] + + def get_filenames(self): + """ + Return all filenames for which there is data available. + """ + return [filename for filename, _data in self.rdata] + + def get_data(self, filename): + """ + Get and load code analysis data for given `filename`. + """ + filename = osp.abspath(filename) + for index, (fname, data) in enumerate(self.rdata): + if fname == filename: + return index, data + else: + return None, None + + def set_data(self, filename, data): + """ + Set and save code analysis `data` for given `filename`. + """ + filename = osp.abspath(filename) + index, _data = self.get_data(filename) + if index is not None: + self.rdata.pop(index) + + self.rdata.insert(0, (filename, data)) + + while len(self.rdata) > self.get_conf("max_entries"): + self.rdata.pop(-1) + + with open(self.DATAPATH, "wb") as fh: + pickle.dump([self.VERSION] + self.rdata, fh, 2) + + def show_data(self, justanalyzed=False): + """ + Show data in treewidget. + """ + text_color = MAIN_TEXT_COLOR + prevrate_color = MAIN_PREVRATE_COLOR + + if not justanalyzed: + self.output = None + + self.log_action.setEnabled(self.output is not None + and len(self.output) > 0) + + if self._is_running(): + self._kill_process() + + filename = self.get_filename() + if not filename: + return + + _index, data = self.get_data(filename) + if data is None: + text = _("Source code has not been rated yet.") + self.treewidget.clear_results() + date_text = "" + else: + datetime, rate, previous_rate, results = data + if rate is None: + text = _("Analysis did not succeed " + "(see output for more details).") + self.treewidget.clear_results() + date_text = "" + else: + text_style = "%s " + rate_style = "%s" + prevrate_style = "%s" + color = DANGER_COLOR + if float(rate) > 5.: + color = SUCCESS_COLOR + elif float(rate) > 3.: + color = WARNING_COLOR + + text = _("Global evaluation:") + text = ((text_style % (text_color, text)) + + (rate_style % (color, ("%s/10" % rate)))) + if previous_rate: + text_prun = _("previous run:") + text_prun = " (%s %s/10)" % (text_prun, previous_rate) + text += prevrate_style % (prevrate_color, text_prun) + + self.treewidget.set_results(filename, results) + date = time.strftime("%Y-%m-%d %H:%M:%S", datetime) + date_text = text_style % (text_color, date) + + self.ratelabel.setText(text) + self.datelabel.setText(date_text) + + @Slot() + def show_log(self): + """ + Show output log dialog. + """ + if self.output: + output_dialog = TextEditor( + self.output, + title=_("Code analysis output"), + parent=self, + readonly=True + ) + output_dialog.resize(700, 500) + output_dialog.exec_() + + # --- Python Specific + # ------------------------------------------------------------------------ + def get_pylintrc_path(self, filename): + """ + Get the path to the most proximate pylintrc config to the file. + """ + search_paths = [ + # File"s directory + osp.dirname(filename), + # Working directory + getcwd_or_home(), + # Project directory + self.get_conf("project_dir"), + # Home directory + osp.expanduser("~"), + ] + + return get_pylintrc_path(search_paths=search_paths) + + @Slot() + def select_file(self, filename=None): + """ + Select filename using a open file dialog and set as current filename. + + If `filename` is provided, the dialog is not used. + """ + if filename is None: + self.sig_redirect_stdio_requested.emit(False) + filename, _selfilter = getopenfilename( + self, + _("Select Python file"), + getcwd_or_home(), + _("Python files") + " (*.py ; *.pyw)", + ) + self.sig_redirect_stdio_requested.emit(True) + + if filename: + self.set_filename(filename) + self.start_code_analysis() + + def get_command(self, filename): + """ + Return command to use to run code analysis on given filename + """ + command_args = [] + if PYLINT_VER is not None: + command_args = [ + "-m", + "pylint", + "--output-format=text", + "--msg-template=" + '{msg_id}:{symbol}:{line:3d},{column}: {msg}"', + ] + + pylintrc_path = self.get_pylintrc_path(filename=filename) + if pylintrc_path is not None: + command_args += ["--rcfile={}".format(pylintrc_path)] + + command_args.append(filename) + return command_args + + def parse_output(self, output): + """ + Parse output and return current revious rate and results. + """ + # Convention, Refactor, Warning, Error + results = {"C:": [], "R:": [], "W:": [], "E:": []} + txt_module = "************* Module " + + module = "" # Should not be needed - just in case something goes wrong + for line in output.splitlines(): + if line.startswith(txt_module): + # New module + module = line[len(txt_module):] + continue + # Supporting option include-ids: ("R3873:" instead of "R:") + if not re.match(r"^[CRWE]+([0-9]{4})?:", line): + continue + + items = {} + idx_0 = 0 + idx_1 = 0 + key_names = ["msg_id", "message_name", "line_nb", "message"] + for key_idx, key_name in enumerate(key_names): + if key_idx == len(key_names) - 1: + idx_1 = len(line) + else: + idx_1 = line.find(":", idx_0) + + if idx_1 < 0: + break + + item = line[(idx_0):idx_1] + if not item: + break + + if key_name == "line_nb": + item = int(item.split(",")[0]) + + items[key_name] = item + idx_0 = idx_1 + 1 + else: + pylint_item = (module, items["line_nb"], items["message"], + items["msg_id"], items["message_name"]) + results[line[0] + ":"].append(pylint_item) + + # Rate + rate = None + txt_rate = "Your code has been rated at " + i_rate = output.find(txt_rate) + if i_rate > 0: + i_rate_end = output.find("/10", i_rate) + if i_rate_end > 0: + rate = output[i_rate+len(txt_rate):i_rate_end] + + # Previous run + previous = "" + if rate is not None: + txt_prun = "previous run: " + i_prun = output.find(txt_prun, i_rate_end) + if i_prun > 0: + i_prun_end = output.find("/10", i_prun) + previous = output[i_prun+len(txt_prun):i_prun_end] + + return rate, previous, results + + +# ============================================================================= +# Tests +# ============================================================================= +def test(): + """Run pylint widget test""" + from spyder.utils.qthelpers import qapplication + from unittest.mock import MagicMock + + plugin_mock = MagicMock() + plugin_mock.CONF_SECTION = 'pylint' + + app = qapplication(test_time=20) + widget = PylintWidget(name="pylint", plugin=plugin_mock) + widget._setup() + widget.setup() + widget.resize(640, 480) + widget.show() + widget.start_code_analysis(filename=__file__) + sys.exit(app.exec_()) + + +if __name__ == "__main__": + test() diff --git a/spyder/plugins/pylint/plugin.py b/spyder/plugins/pylint/plugin.py index 1adf79faeee..fb82522e9a9 100644 --- a/spyder/plugins/pylint/plugin.py +++ b/spyder/plugins/pylint/plugin.py @@ -1,236 +1,236 @@ -# -*- coding: utf-8 -*- -# -# Copyright © Spyder Project Contributors -# Licensed under the terms of the MIT License -# (see spyder/__init__.py for details) - -""" -Pylint Code Analysis Plugin. -""" - -# Standard library imports -import os.path as osp - -# Third party imports -from qtpy.QtCore import Qt, Signal, Slot - -# Local imports -from spyder.api.exceptions import SpyderAPIError -from spyder.api.plugins import Plugins, SpyderDockablePlugin -from spyder.api.plugin_registration.decorators import ( - on_plugin_available, on_plugin_teardown) -from spyder.api.translations import get_translation -from spyder.utils.programs import is_module_installed -from spyder.plugins.mainmenu.api import ApplicationMenus -from spyder.plugins.pylint.confpage import PylintConfigPage -from spyder.plugins.pylint.main_widget import (PylintWidget, - PylintWidgetActions) - - -# Localization -_ = get_translation("spyder") - - -class PylintActions: - AnalyzeCurrentFile = 'run analysis' - - -class Pylint(SpyderDockablePlugin): - - NAME = "pylint" - WIDGET_CLASS = PylintWidget - CONF_SECTION = NAME - CONF_WIDGET_CLASS = PylintConfigPage - REQUIRES = [Plugins.Preferences, Plugins.Editor] - OPTIONAL = [Plugins.MainMenu, Plugins.Projects] - CONF_FILE = False - DISABLE_ACTIONS_WHEN_HIDDEN = False - - # --- Signals - sig_edit_goto_requested = Signal(str, int, str) - """ - This signal will request to open a file in a given row and column - using a code editor. - - Parameters - ---------- - path: str - Path to file. - row: int - Cursor starting row position. - word: str - Word to select on given row. - """ - - @staticmethod - def get_name(): - return _("Code Analysis") - - def get_description(self): - return _("Run Code Analysis.") - - def get_icon(self): - return self.create_icon("pylint") - - def on_initialize(self): - widget = self.get_widget() - - # Expose widget signals at the plugin level - widget.sig_edit_goto_requested.connect(self.sig_edit_goto_requested) - widget.sig_redirect_stdio_requested.connect( - self.sig_redirect_stdio_requested) - widget.sig_start_analysis_requested.connect( - lambda: self.start_code_analysis()) - - # Add action to application menus - pylint_act = self.create_action( - PylintActions.AnalyzeCurrentFile, - text=_("Run code analysis"), - tip=_("Run code analysis"), - icon=self.create_icon("pylint"), - triggered=lambda: self.start_code_analysis(), - context=Qt.ApplicationShortcut, - register_shortcut=True - ) - pylint_act.setEnabled(is_module_installed("pylint")) - - @on_plugin_available(plugin=Plugins.Editor) - def on_editor_available(self): - widget = self.get_widget() - editor = self.get_plugin(Plugins.Editor) - - # Connect to Editor - widget.sig_edit_goto_requested.connect(editor.load) - editor.sig_editor_focus_changed.connect(self._set_filename) - - pylint_act = self.get_action(PylintActions.AnalyzeCurrentFile) - - # TODO: use new API when editor has migrated - editor.pythonfile_dependent_actions += [pylint_act] - - @on_plugin_available(plugin=Plugins.Preferences) - def on_preferences_available(self): - preferences = self.get_plugin(Plugins.Preferences) - preferences.register_plugin_preferences(self) - - @on_plugin_available(plugin=Plugins.Projects) - def on_projects_available(self): - widget = self.get_widget() - - # Connect to projects - projects = self.get_plugin(Plugins.Projects) - - projects.sig_project_loaded.connect(self._set_project_dir) - projects.sig_project_closed.connect(self._unset_project_dir) - - @on_plugin_available(plugin=Plugins.MainMenu) - def on_main_menu_available(self): - mainmenu = self.get_plugin(Plugins.MainMenu) - - pylint_act = self.get_action(PylintActions.AnalyzeCurrentFile) - mainmenu.add_item_to_application_menu( - pylint_act, menu_id=ApplicationMenus.Source) - - @on_plugin_teardown(plugin=Plugins.Editor) - def on_editor_teardown(self): - widget = self.get_widget() - editor = self.get_plugin(Plugins.Editor) - - # Connect to Editor - widget.sig_edit_goto_requested.disconnect(editor.load) - editor.sig_editor_focus_changed.disconnect(self._set_filename) - - pylint_act = self.get_action(PylintActions.AnalyzeCurrentFile) - - # TODO: use new API when editor has migrated - pylint_act.setVisible(False) - editor.pythonfile_dependent_actions.remove(pylint_act) - - @on_plugin_teardown(plugin=Plugins.Preferences) - def on_preferences_teardown(self): - preferences = self.get_plugin(Plugins.Preferences) - preferences.deregister_plugin_preferences(self) - - @on_plugin_teardown(plugin=Plugins.Projects) - def on_projects_teardown(self): - # Disconnect from projects - projects = self.get_plugin(Plugins.Projects) - projects.sig_project_loaded.disconnect(self._set_project_dir) - projects.sig_project_closed.disconnect(self._unset_project_dir) - - @on_plugin_teardown(plugin=Plugins.MainMenu) - def on_main_menu_teardown(self): - mainmenu = self.get_plugin(Plugins.MainMenu) - mainmenu.remove_item_from_application_menu( - PylintActions.AnalyzeCurrentFile, - menu_id=ApplicationMenus.Source - ) - - # --- Private API - # ------------------------------------------------------------------------ - @Slot() - def _set_filename(self): - """ - Set filename without code analysis. - """ - try: - editor = self.get_plugin(Plugins.Editor) - if editor: - self.get_widget().set_filename(editor.get_current_filename()) - except SpyderAPIError: - # Editor was deleted - pass - - def _set_project_dir(self, value): - widget = self.get_widget() - widget.set_conf("project_dir", value) - - def _unset_project_dir(self, _unused): - widget = self.get_widget() - widget.set_conf("project_dir", None) - - # --- Public API - # ------------------------------------------------------------------------ - def change_history_depth(self, value=None): - """ - Change history maximum number of entries. - - Parameters - ---------- - value: int or None, optional - The valur to set the maximum history depth. If no value is - provided, an input dialog will be launched. Default is None. - """ - self.get_widget().change_history_depth(value=value) - - def get_filename(self): - """ - Get current filename in combobox. - """ - return self.get_widget().get_filename() - - def start_code_analysis(self, filename=None): - """ - Perform code analysis for given `filename`. - - If `filename` is None default to current filename in combobox. - - If this method is called while still running it will stop the code - analysis. - """ - editor = self.get_plugin(Plugins.Editor) - if editor: - if self.get_conf("save_before", True) and not editor.save(): - return - - if filename is None: - filename = self.get_widget().get_filename() - - self.switch_to_plugin(force_focus=True) - self.get_widget().start_code_analysis(filename) - - def stop_code_analysis(self): - """ - Stop the code analysis process. - """ - self.get_widget().stop_code_analysis() +# -*- coding: utf-8 -*- +# +# Copyright © Spyder Project Contributors +# Licensed under the terms of the MIT License +# (see spyder/__init__.py for details) + +""" +Pylint Code Analysis Plugin. +""" + +# Standard library imports +import os.path as osp + +# Third party imports +from qtpy.QtCore import Qt, Signal, Slot + +# Local imports +from spyder.api.exceptions import SpyderAPIError +from spyder.api.plugins import Plugins, SpyderDockablePlugin +from spyder.api.plugin_registration.decorators import ( + on_plugin_available, on_plugin_teardown) +from spyder.api.translations import get_translation +from spyder.utils.programs import is_module_installed +from spyder.plugins.mainmenu.api import ApplicationMenus +from spyder.plugins.pylint.confpage import PylintConfigPage +from spyder.plugins.pylint.main_widget import (PylintWidget, + PylintWidgetActions) + + +# Localization +_ = get_translation("spyder") + + +class PylintActions: + AnalyzeCurrentFile = 'run analysis' + + +class Pylint(SpyderDockablePlugin): + + NAME = "pylint" + WIDGET_CLASS = PylintWidget + CONF_SECTION = NAME + CONF_WIDGET_CLASS = PylintConfigPage + REQUIRES = [Plugins.Preferences, Plugins.Editor] + OPTIONAL = [Plugins.MainMenu, Plugins.Projects] + CONF_FILE = False + DISABLE_ACTIONS_WHEN_HIDDEN = False + + # --- Signals + sig_edit_goto_requested = Signal(str, int, str) + """ + This signal will request to open a file in a given row and column + using a code editor. + + Parameters + ---------- + path: str + Path to file. + row: int + Cursor starting row position. + word: str + Word to select on given row. + """ + + @staticmethod + def get_name(): + return _("Code Analysis") + + def get_description(self): + return _("Run Code Analysis.") + + def get_icon(self): + return self.create_icon("pylint") + + def on_initialize(self): + widget = self.get_widget() + + # Expose widget signals at the plugin level + widget.sig_edit_goto_requested.connect(self.sig_edit_goto_requested) + widget.sig_redirect_stdio_requested.connect( + self.sig_redirect_stdio_requested) + widget.sig_start_analysis_requested.connect( + lambda: self.start_code_analysis()) + + # Add action to application menus + pylint_act = self.create_action( + PylintActions.AnalyzeCurrentFile, + text=_("Run code analysis"), + tip=_("Run code analysis"), + icon=self.create_icon("pylint"), + triggered=lambda: self.start_code_analysis(), + context=Qt.ApplicationShortcut, + register_shortcut=True + ) + pylint_act.setEnabled(is_module_installed("pylint")) + + @on_plugin_available(plugin=Plugins.Editor) + def on_editor_available(self): + widget = self.get_widget() + editor = self.get_plugin(Plugins.Editor) + + # Connect to Editor + widget.sig_edit_goto_requested.connect(editor.load) + editor.sig_editor_focus_changed.connect(self._set_filename) + + pylint_act = self.get_action(PylintActions.AnalyzeCurrentFile) + + # TODO: use new API when editor has migrated + editor.pythonfile_dependent_actions += [pylint_act] + + @on_plugin_available(plugin=Plugins.Preferences) + def on_preferences_available(self): + preferences = self.get_plugin(Plugins.Preferences) + preferences.register_plugin_preferences(self) + + @on_plugin_available(plugin=Plugins.Projects) + def on_projects_available(self): + widget = self.get_widget() + + # Connect to projects + projects = self.get_plugin(Plugins.Projects) + + projects.sig_project_loaded.connect(self._set_project_dir) + projects.sig_project_closed.connect(self._unset_project_dir) + + @on_plugin_available(plugin=Plugins.MainMenu) + def on_main_menu_available(self): + mainmenu = self.get_plugin(Plugins.MainMenu) + + pylint_act = self.get_action(PylintActions.AnalyzeCurrentFile) + mainmenu.add_item_to_application_menu( + pylint_act, menu_id=ApplicationMenus.Source) + + @on_plugin_teardown(plugin=Plugins.Editor) + def on_editor_teardown(self): + widget = self.get_widget() + editor = self.get_plugin(Plugins.Editor) + + # Connect to Editor + widget.sig_edit_goto_requested.disconnect(editor.load) + editor.sig_editor_focus_changed.disconnect(self._set_filename) + + pylint_act = self.get_action(PylintActions.AnalyzeCurrentFile) + + # TODO: use new API when editor has migrated + pylint_act.setVisible(False) + editor.pythonfile_dependent_actions.remove(pylint_act) + + @on_plugin_teardown(plugin=Plugins.Preferences) + def on_preferences_teardown(self): + preferences = self.get_plugin(Plugins.Preferences) + preferences.deregister_plugin_preferences(self) + + @on_plugin_teardown(plugin=Plugins.Projects) + def on_projects_teardown(self): + # Disconnect from projects + projects = self.get_plugin(Plugins.Projects) + projects.sig_project_loaded.disconnect(self._set_project_dir) + projects.sig_project_closed.disconnect(self._unset_project_dir) + + @on_plugin_teardown(plugin=Plugins.MainMenu) + def on_main_menu_teardown(self): + mainmenu = self.get_plugin(Plugins.MainMenu) + mainmenu.remove_item_from_application_menu( + PylintActions.AnalyzeCurrentFile, + menu_id=ApplicationMenus.Source + ) + + # --- Private API + # ------------------------------------------------------------------------ + @Slot() + def _set_filename(self): + """ + Set filename without code analysis. + """ + try: + editor = self.get_plugin(Plugins.Editor) + if editor: + self.get_widget().set_filename(editor.get_current_filename()) + except SpyderAPIError: + # Editor was deleted + pass + + def _set_project_dir(self, value): + widget = self.get_widget() + widget.set_conf("project_dir", value) + + def _unset_project_dir(self, _unused): + widget = self.get_widget() + widget.set_conf("project_dir", None) + + # --- Public API + # ------------------------------------------------------------------------ + def change_history_depth(self, value=None): + """ + Change history maximum number of entries. + + Parameters + ---------- + value: int or None, optional + The valur to set the maximum history depth. If no value is + provided, an input dialog will be launched. Default is None. + """ + self.get_widget().change_history_depth(value=value) + + def get_filename(self): + """ + Get current filename in combobox. + """ + return self.get_widget().get_filename() + + def start_code_analysis(self, filename=None): + """ + Perform code analysis for given `filename`. + + If `filename` is None default to current filename in combobox. + + If this method is called while still running it will stop the code + analysis. + """ + editor = self.get_plugin(Plugins.Editor) + if editor: + if self.get_conf("save_before", True) and not editor.save(): + return + + if filename is None: + filename = self.get_widget().get_filename() + + self.switch_to_plugin(force_focus=True) + self.get_widget().start_code_analysis(filename) + + def stop_code_analysis(self): + """ + Stop the code analysis process. + """ + self.get_widget().stop_code_analysis() diff --git a/spyder/plugins/run/confpage.py b/spyder/plugins/run/confpage.py index 5ad5663d515..287a35f7be3 100644 --- a/spyder/plugins/run/confpage.py +++ b/spyder/plugins/run/confpage.py @@ -1,142 +1,142 @@ -# -*- coding: utf-8 -*- -# -# Copyright © Spyder Project Contributors -# Licensed under the terms of the MIT License -# (see spyder/__init__.py for details) - -"""Run configuration page.""" - -# Third party imports -from qtpy.QtWidgets import (QButtonGroup, QGroupBox, QHBoxLayout, QLabel, - QVBoxLayout) - -# Local imports -from spyder.api.preferences import PluginConfigPage -from spyder.api.translations import get_translation -from spyder.plugins.run.widgets import (ALWAYS_OPEN_FIRST_RUN, - ALWAYS_OPEN_FIRST_RUN_OPTION, - CLEAR_ALL_VARIABLES, - CONSOLE_NAMESPACE, - CURRENT_INTERPRETER, - CURRENT_INTERPRETER_OPTION, CW_DIR, - DEDICATED_INTERPRETER, - DEDICATED_INTERPRETER_OPTION, - FILE_DIR, FIXED_DIR, INTERACT, - POST_MORTEM, SYSTERM_INTERPRETER, - SYSTERM_INTERPRETER_OPTION, - WDIR_FIXED_DIR_OPTION, - WDIR_USE_CWD_DIR_OPTION, - WDIR_USE_FIXED_DIR_OPTION, - WDIR_USE_SCRIPT_DIR_OPTION) -from spyder.utils.misc import getcwd_or_home - -# Localization -_ = get_translation("spyder") - - -class RunConfigPage(PluginConfigPage): - """Default Run Settings configuration page.""" - - def setup_page(self): - about_label = QLabel(_("The following are the default options for " - "running files.These options may be overriden " - "using the Configuration per file entry " - "of the Run menu.")) - about_label.setWordWrap(True) - - interpreter_group = QGroupBox(_("Console")) - interpreter_bg = QButtonGroup(interpreter_group) - self.current_radio = self.create_radiobutton( - CURRENT_INTERPRETER, - CURRENT_INTERPRETER_OPTION, - True, - button_group=interpreter_bg) - self.dedicated_radio = self.create_radiobutton( - DEDICATED_INTERPRETER, - DEDICATED_INTERPRETER_OPTION, - False, - button_group=interpreter_bg) - self.systerm_radio = self.create_radiobutton( - SYSTERM_INTERPRETER, - SYSTERM_INTERPRETER_OPTION, False, - button_group=interpreter_bg) - - interpreter_layout = QVBoxLayout() - interpreter_group.setLayout(interpreter_layout) - interpreter_layout.addWidget(self.current_radio) - interpreter_layout.addWidget(self.dedicated_radio) - interpreter_layout.addWidget(self.systerm_radio) - - general_group = QGroupBox(_("General settings")) - post_mortem = self.create_checkbox(POST_MORTEM, 'post_mortem', False) - clear_variables = self.create_checkbox(CLEAR_ALL_VARIABLES, - 'clear_namespace', False) - console_namespace = self.create_checkbox(CONSOLE_NAMESPACE, - 'console_namespace', False) - - general_layout = QVBoxLayout() - general_layout.addWidget(clear_variables) - general_layout.addWidget(console_namespace) - general_layout.addWidget(post_mortem) - general_group.setLayout(general_layout) - - wdir_group = QGroupBox(_("Working directory settings")) - wdir_bg = QButtonGroup(wdir_group) - wdir_label = QLabel(_("Default working directory is:")) - wdir_label.setWordWrap(True) - dirname_radio = self.create_radiobutton( - FILE_DIR, - WDIR_USE_SCRIPT_DIR_OPTION, - True, - button_group=wdir_bg) - cwd_radio = self.create_radiobutton( - CW_DIR, - WDIR_USE_CWD_DIR_OPTION, - False, - button_group=wdir_bg) - - thisdir_radio = self.create_radiobutton( - FIXED_DIR, - WDIR_USE_FIXED_DIR_OPTION, - False, - button_group=wdir_bg) - thisdir_bd = self.create_browsedir("", WDIR_FIXED_DIR_OPTION, - getcwd_or_home()) - thisdir_radio.toggled.connect(thisdir_bd.setEnabled) - dirname_radio.toggled.connect(thisdir_bd.setDisabled) - cwd_radio.toggled.connect(thisdir_bd.setDisabled) - thisdir_layout = QHBoxLayout() - thisdir_layout.addWidget(thisdir_radio) - thisdir_layout.addWidget(thisdir_bd) - - wdir_layout = QVBoxLayout() - wdir_layout.addWidget(wdir_label) - wdir_layout.addWidget(dirname_radio) - wdir_layout.addWidget(cwd_radio) - wdir_layout.addLayout(thisdir_layout) - wdir_group.setLayout(wdir_layout) - - external_group = QGroupBox(_("External system terminal")) - interact_after = self.create_checkbox(INTERACT, 'interact', False) - - external_layout = QVBoxLayout() - external_layout.addWidget(interact_after) - external_group.setLayout(external_layout) - - firstrun_cb = self.create_checkbox( - ALWAYS_OPEN_FIRST_RUN % _("Run Settings dialog"), - ALWAYS_OPEN_FIRST_RUN_OPTION, - False) - - vlayout = QVBoxLayout(self) - vlayout.addWidget(about_label) - vlayout.addSpacing(10) - vlayout.addWidget(interpreter_group) - vlayout.addWidget(general_group) - vlayout.addWidget(wdir_group) - vlayout.addWidget(external_group) - vlayout.addWidget(firstrun_cb) - vlayout.addStretch(1) - - def apply_settings(self): - pass +# -*- coding: utf-8 -*- +# +# Copyright © Spyder Project Contributors +# Licensed under the terms of the MIT License +# (see spyder/__init__.py for details) + +"""Run configuration page.""" + +# Third party imports +from qtpy.QtWidgets import (QButtonGroup, QGroupBox, QHBoxLayout, QLabel, + QVBoxLayout) + +# Local imports +from spyder.api.preferences import PluginConfigPage +from spyder.api.translations import get_translation +from spyder.plugins.run.widgets import (ALWAYS_OPEN_FIRST_RUN, + ALWAYS_OPEN_FIRST_RUN_OPTION, + CLEAR_ALL_VARIABLES, + CONSOLE_NAMESPACE, + CURRENT_INTERPRETER, + CURRENT_INTERPRETER_OPTION, CW_DIR, + DEDICATED_INTERPRETER, + DEDICATED_INTERPRETER_OPTION, + FILE_DIR, FIXED_DIR, INTERACT, + POST_MORTEM, SYSTERM_INTERPRETER, + SYSTERM_INTERPRETER_OPTION, + WDIR_FIXED_DIR_OPTION, + WDIR_USE_CWD_DIR_OPTION, + WDIR_USE_FIXED_DIR_OPTION, + WDIR_USE_SCRIPT_DIR_OPTION) +from spyder.utils.misc import getcwd_or_home + +# Localization +_ = get_translation("spyder") + + +class RunConfigPage(PluginConfigPage): + """Default Run Settings configuration page.""" + + def setup_page(self): + about_label = QLabel(_("The following are the default options for " + "running files.These options may be overriden " + "using the Configuration per file entry " + "of the Run menu.")) + about_label.setWordWrap(True) + + interpreter_group = QGroupBox(_("Console")) + interpreter_bg = QButtonGroup(interpreter_group) + self.current_radio = self.create_radiobutton( + CURRENT_INTERPRETER, + CURRENT_INTERPRETER_OPTION, + True, + button_group=interpreter_bg) + self.dedicated_radio = self.create_radiobutton( + DEDICATED_INTERPRETER, + DEDICATED_INTERPRETER_OPTION, + False, + button_group=interpreter_bg) + self.systerm_radio = self.create_radiobutton( + SYSTERM_INTERPRETER, + SYSTERM_INTERPRETER_OPTION, False, + button_group=interpreter_bg) + + interpreter_layout = QVBoxLayout() + interpreter_group.setLayout(interpreter_layout) + interpreter_layout.addWidget(self.current_radio) + interpreter_layout.addWidget(self.dedicated_radio) + interpreter_layout.addWidget(self.systerm_radio) + + general_group = QGroupBox(_("General settings")) + post_mortem = self.create_checkbox(POST_MORTEM, 'post_mortem', False) + clear_variables = self.create_checkbox(CLEAR_ALL_VARIABLES, + 'clear_namespace', False) + console_namespace = self.create_checkbox(CONSOLE_NAMESPACE, + 'console_namespace', False) + + general_layout = QVBoxLayout() + general_layout.addWidget(clear_variables) + general_layout.addWidget(console_namespace) + general_layout.addWidget(post_mortem) + general_group.setLayout(general_layout) + + wdir_group = QGroupBox(_("Working directory settings")) + wdir_bg = QButtonGroup(wdir_group) + wdir_label = QLabel(_("Default working directory is:")) + wdir_label.setWordWrap(True) + dirname_radio = self.create_radiobutton( + FILE_DIR, + WDIR_USE_SCRIPT_DIR_OPTION, + True, + button_group=wdir_bg) + cwd_radio = self.create_radiobutton( + CW_DIR, + WDIR_USE_CWD_DIR_OPTION, + False, + button_group=wdir_bg) + + thisdir_radio = self.create_radiobutton( + FIXED_DIR, + WDIR_USE_FIXED_DIR_OPTION, + False, + button_group=wdir_bg) + thisdir_bd = self.create_browsedir("", WDIR_FIXED_DIR_OPTION, + getcwd_or_home()) + thisdir_radio.toggled.connect(thisdir_bd.setEnabled) + dirname_radio.toggled.connect(thisdir_bd.setDisabled) + cwd_radio.toggled.connect(thisdir_bd.setDisabled) + thisdir_layout = QHBoxLayout() + thisdir_layout.addWidget(thisdir_radio) + thisdir_layout.addWidget(thisdir_bd) + + wdir_layout = QVBoxLayout() + wdir_layout.addWidget(wdir_label) + wdir_layout.addWidget(dirname_radio) + wdir_layout.addWidget(cwd_radio) + wdir_layout.addLayout(thisdir_layout) + wdir_group.setLayout(wdir_layout) + + external_group = QGroupBox(_("External system terminal")) + interact_after = self.create_checkbox(INTERACT, 'interact', False) + + external_layout = QVBoxLayout() + external_layout.addWidget(interact_after) + external_group.setLayout(external_layout) + + firstrun_cb = self.create_checkbox( + ALWAYS_OPEN_FIRST_RUN % _("Run Settings dialog"), + ALWAYS_OPEN_FIRST_RUN_OPTION, + False) + + vlayout = QVBoxLayout(self) + vlayout.addWidget(about_label) + vlayout.addSpacing(10) + vlayout.addWidget(interpreter_group) + vlayout.addWidget(general_group) + vlayout.addWidget(wdir_group) + vlayout.addWidget(external_group) + vlayout.addWidget(firstrun_cb) + vlayout.addStretch(1) + + def apply_settings(self): + pass diff --git a/spyder/plugins/run/plugin.py b/spyder/plugins/run/plugin.py index c8f196991ab..feef5b45db1 100644 --- a/spyder/plugins/run/plugin.py +++ b/spyder/plugins/run/plugin.py @@ -1,65 +1,65 @@ -# -*- coding: utf-8 -*- -# ----------------------------------------------------------------------------- -# Copyright (c) 2009- Spyder Project Contributors -# -# Distributed under the terms of the MIT License -# (see spyder/__init__.py for details) -# ----------------------------------------------------------------------------- - -""" -Run Plugin. -""" - -# Local imports -from spyder.api.plugins import Plugins, SpyderPluginV2 -from spyder.api.plugin_registration.decorators import ( - on_plugin_available, on_plugin_teardown) -from spyder.api.translations import get_translation -from spyder.plugins.run.confpage import RunConfigPage - -# Localization -_ = get_translation('spyder') - - -# --- Plugin -# ---------------------------------------------------------------------------- -class Run(SpyderPluginV2): - """ - Run Plugin. - """ - - NAME = "run" - # TODO: Fix requires to reflect the desired order in the preferences - REQUIRES = [Plugins.Preferences] - CONTAINER_CLASS = None - CONF_SECTION = NAME - CONF_WIDGET_CLASS = RunConfigPage - CONF_FILE = False - - # --- SpyderPluginV2 API - # ------------------------------------------------------------------------ - @staticmethod - def get_name(): - return _("Run") - - def get_description(self): - return _("Manage run configuration.") - - def get_icon(self): - return self.create_icon('run') - - def on_initialize(self): - pass - - @on_plugin_available(plugin=Plugins.Preferences) - def on_preferences_available(self): - preferences = self.get_plugin(Plugins.Preferences) - preferences.register_plugin_preferences(self) - - @on_plugin_teardown(plugin=Plugins.Preferences) - def on_preferences_teardown(self): - preferences = self.get_plugin(Plugins.Preferences) - preferences.deregister_plugin_preferences(self) - - # --- Public API - # ------------------------------------------------------------------------ +# -*- coding: utf-8 -*- +# ----------------------------------------------------------------------------- +# Copyright (c) 2009- Spyder Project Contributors +# +# Distributed under the terms of the MIT License +# (see spyder/__init__.py for details) +# ----------------------------------------------------------------------------- + +""" +Run Plugin. +""" + +# Local imports +from spyder.api.plugins import Plugins, SpyderPluginV2 +from spyder.api.plugin_registration.decorators import ( + on_plugin_available, on_plugin_teardown) +from spyder.api.translations import get_translation +from spyder.plugins.run.confpage import RunConfigPage + +# Localization +_ = get_translation('spyder') + + +# --- Plugin +# ---------------------------------------------------------------------------- +class Run(SpyderPluginV2): + """ + Run Plugin. + """ + + NAME = "run" + # TODO: Fix requires to reflect the desired order in the preferences + REQUIRES = [Plugins.Preferences] + CONTAINER_CLASS = None + CONF_SECTION = NAME + CONF_WIDGET_CLASS = RunConfigPage + CONF_FILE = False + + # --- SpyderPluginV2 API + # ------------------------------------------------------------------------ + @staticmethod + def get_name(): + return _("Run") + + def get_description(self): + return _("Manage run configuration.") + + def get_icon(self): + return self.create_icon('run') + + def on_initialize(self): + pass + + @on_plugin_available(plugin=Plugins.Preferences) + def on_preferences_available(self): + preferences = self.get_plugin(Plugins.Preferences) + preferences.register_plugin_preferences(self) + + @on_plugin_teardown(plugin=Plugins.Preferences) + def on_preferences_teardown(self): + preferences = self.get_plugin(Plugins.Preferences) + preferences.deregister_plugin_preferences(self) + + # --- Public API + # ------------------------------------------------------------------------ diff --git a/spyder/plugins/run/widgets.py b/spyder/plugins/run/widgets.py index 69ed9cccb6e..96f414a41bb 100644 --- a/spyder/plugins/run/widgets.py +++ b/spyder/plugins/run/widgets.py @@ -1,522 +1,522 @@ -# -*- coding: utf-8 -*- -# -# Copyright © Spyder Project Contributors -# Licensed under the terms of the MIT License -# (see spyder/__init__.py for details) - -"""Run dialogs and widgets and data models.""" - -# Standard library imports -import os.path as osp - -# Third party imports -from qtpy.compat import getexistingdirectory -from qtpy.QtCore import QSize, Qt, Signal, Slot -from qtpy.QtWidgets import (QCheckBox, QComboBox, QDialog, QDialogButtonBox, - QFrame, QGridLayout, QGroupBox, QHBoxLayout, - QLabel, QLineEdit, QMessageBox, QPushButton, - QRadioButton, QSizePolicy, QScrollArea, - QStackedWidget, QVBoxLayout, QWidget) - -# Local imports -from spyder.api.translations import get_translation -from spyder.config.manager import CONF -from spyder.utils.icon_manager import ima -from spyder.utils.misc import getcwd_or_home -from spyder.utils.qthelpers import create_toolbutton - -# Localization -_ = get_translation("spyder") - -RUN_DEFAULT_CONFIG = _("Run file with default configuration") -RUN_CUSTOM_CONFIG = _("Run file with custom configuration") -CURRENT_INTERPRETER = _("Execute in current console") -DEDICATED_INTERPRETER = _("Execute in a dedicated console") -SYSTERM_INTERPRETER = _("Execute in an external system terminal") - -CURRENT_INTERPRETER_OPTION = 'default/interpreter/current' -DEDICATED_INTERPRETER_OPTION = 'default/interpreter/dedicated' -SYSTERM_INTERPRETER_OPTION = 'default/interpreter/systerm' - -WDIR_USE_SCRIPT_DIR_OPTION = 'default/wdir/use_script_directory' -WDIR_USE_CWD_DIR_OPTION = 'default/wdir/use_cwd_directory' -WDIR_USE_FIXED_DIR_OPTION = 'default/wdir/use_fixed_directory' -WDIR_FIXED_DIR_OPTION = 'default/wdir/fixed_directory' - -ALWAYS_OPEN_FIRST_RUN = _("Always show %s on a first file run") -ALWAYS_OPEN_FIRST_RUN_OPTION = 'open_on_firstrun' - -CLEAR_ALL_VARIABLES = _("Remove all variables before execution") -CONSOLE_NAMESPACE = _("Run in console's namespace instead of an empty one") -POST_MORTEM = _("Directly enter debugging when errors appear") -INTERACT = _("Interact with the Python console after execution") - -FILE_DIR = _("The directory of the file being executed") -CW_DIR = _("The current working directory") -FIXED_DIR = _("The following directory:") - - -class RunConfiguration(object): - """Run configuration""" - - def __init__(self, fname=None): - self.default = None - self.args = None - self.args_enabled = None - self.wdir = None - self.wdir_enabled = None - self.current = None - self.systerm = None - self.interact = None - self.post_mortem = None - self.python_args = None - self.python_args_enabled = None - self.clear_namespace = None - self.console_namespace = None - self.file_dir = None - self.cw_dir = None - self.fixed_dir = None - self.dir = None - - self.set(CONF.get('run', 'defaultconfiguration', default={})) - - def set(self, options): - self.default = options.get('default', True) - self.args = options.get('args', '') - self.args_enabled = options.get('args/enabled', False) - self.current = options.get('current', - CONF.get('run', CURRENT_INTERPRETER_OPTION, True)) - self.systerm = options.get('systerm', - CONF.get('run', SYSTERM_INTERPRETER_OPTION, False)) - self.interact = options.get('interact', - CONF.get('run', 'interact', False)) - self.post_mortem = options.get('post_mortem', - CONF.get('run', 'post_mortem', False)) - self.python_args = options.get('python_args', '') - self.python_args_enabled = options.get('python_args/enabled', False) - self.clear_namespace = options.get('clear_namespace', - CONF.get('run', 'clear_namespace', False)) - self.console_namespace = options.get('console_namespace', - CONF.get('run', 'console_namespace', False)) - self.file_dir = options.get('file_dir', - CONF.get('run', WDIR_USE_SCRIPT_DIR_OPTION, True)) - self.cw_dir = options.get('cw_dir', - CONF.get('run', WDIR_USE_CWD_DIR_OPTION, False)) - self.fixed_dir = options.get('fixed_dir', - CONF.get('run', WDIR_USE_FIXED_DIR_OPTION, False)) - self.dir = options.get('dir', '') - - def get(self): - return { - 'default': self.default, - 'args/enabled': self.args_enabled, - 'args': self.args, - 'workdir/enabled': self.wdir_enabled, - 'workdir': self.wdir, - 'current': self.current, - 'systerm': self.systerm, - 'interact': self.interact, - 'post_mortem': self.post_mortem, - 'python_args/enabled': self.python_args_enabled, - 'python_args': self.python_args, - 'clear_namespace': self.clear_namespace, - 'console_namespace': self.console_namespace, - 'file_dir': self.file_dir, - 'cw_dir': self.cw_dir, - 'fixed_dir': self.fixed_dir, - 'dir': self.dir - } - - def get_working_directory(self): - return self.dir - - def get_arguments(self): - if self.args_enabled: - return self.args - else: - return '' - - def get_python_arguments(self): - if self.python_args_enabled: - return self.python_args - else: - return '' - - -def _get_run_configurations(): - history_count = CONF.get('run', 'history', 20) - try: - return [(filename, options) - for filename, options in CONF.get('run', 'configurations', []) - if osp.isfile(filename)][:history_count] - except ValueError: - CONF.set('run', 'configurations', []) - return [] - - -def _set_run_configurations(configurations): - history_count = CONF.get('run', 'history', 20) - CONF.set('run', 'configurations', configurations[:history_count]) - - -def get_run_configuration(fname): - """Return script *fname* run configuration""" - configurations = _get_run_configurations() - for filename, options in configurations: - if fname == filename: - runconf = RunConfiguration() - runconf.set(options) - return runconf - - -class RunConfigOptions(QWidget): - """Run configuration options""" - def __init__(self, parent=None): - QWidget.__init__(self, parent) - - self.dir = None - self.runconf = RunConfiguration() - firstrun_o = CONF.get('run', ALWAYS_OPEN_FIRST_RUN_OPTION, False) - - # --- Run settings --- - self.run_default_config_radio = QRadioButton(RUN_DEFAULT_CONFIG) - self.run_custom_config_radio = QRadioButton(RUN_CUSTOM_CONFIG) - - # --- Interpreter --- - interpreter_group = QGroupBox(_("Console")) - interpreter_group.setDisabled(True) - - self.run_custom_config_radio.toggled.connect( - interpreter_group.setEnabled) - - interpreter_layout = QVBoxLayout(interpreter_group) - - self.current_radio = QRadioButton(CURRENT_INTERPRETER) - interpreter_layout.addWidget(self.current_radio) - - self.dedicated_radio = QRadioButton(DEDICATED_INTERPRETER) - interpreter_layout.addWidget(self.dedicated_radio) - - self.systerm_radio = QRadioButton(SYSTERM_INTERPRETER) - interpreter_layout.addWidget(self.systerm_radio) - - # --- System terminal --- - external_group = QWidget() - external_group.setDisabled(True) - self.systerm_radio.toggled.connect(external_group.setEnabled) - - external_layout = QGridLayout() - external_group.setLayout(external_layout) - self.interact_cb = QCheckBox(INTERACT) - external_layout.addWidget(self.interact_cb, 1, 0, 1, -1) - - self.pclo_cb = QCheckBox(_("Command line options:")) - external_layout.addWidget(self.pclo_cb, 3, 0) - self.pclo_edit = QLineEdit() - self.pclo_cb.toggled.connect(self.pclo_edit.setEnabled) - self.pclo_edit.setEnabled(False) - self.pclo_edit.setToolTip(_("-u is added to the " - "other options you set here")) - external_layout.addWidget(self.pclo_edit, 3, 1) - - interpreter_layout.addWidget(external_group) - - # --- General settings ---- - common_group = QGroupBox(_("General settings")) - common_group.setDisabled(True) - - self.run_custom_config_radio.toggled.connect(common_group.setEnabled) - - common_layout = QGridLayout(common_group) - - self.clear_var_cb = QCheckBox(CLEAR_ALL_VARIABLES) - common_layout.addWidget(self.clear_var_cb, 0, 0) - - self.console_ns_cb = QCheckBox(CONSOLE_NAMESPACE) - common_layout.addWidget(self.console_ns_cb, 1, 0) - - self.post_mortem_cb = QCheckBox(POST_MORTEM) - common_layout.addWidget(self.post_mortem_cb, 2, 0) - - self.clo_cb = QCheckBox(_("Command line options:")) - common_layout.addWidget(self.clo_cb, 3, 0) - self.clo_edit = QLineEdit() - self.clo_cb.toggled.connect(self.clo_edit.setEnabled) - self.clo_edit.setEnabled(False) - common_layout.addWidget(self.clo_edit, 3, 1) - - # --- Working directory --- - wdir_group = QGroupBox(_("Working directory settings")) - wdir_group.setDisabled(True) - - self.run_custom_config_radio.toggled.connect(wdir_group.setEnabled) - - wdir_layout = QVBoxLayout(wdir_group) - - self.file_dir_radio = QRadioButton(FILE_DIR) - wdir_layout.addWidget(self.file_dir_radio) - - self.cwd_radio = QRadioButton(CW_DIR) - wdir_layout.addWidget(self.cwd_radio) - - fixed_dir_layout = QHBoxLayout() - self.fixed_dir_radio = QRadioButton(FIXED_DIR) - fixed_dir_layout.addWidget(self.fixed_dir_radio) - self.wd_edit = QLineEdit() - self.fixed_dir_radio.toggled.connect(self.wd_edit.setEnabled) - self.wd_edit.setEnabled(False) - fixed_dir_layout.addWidget(self.wd_edit) - browse_btn = create_toolbutton( - self, - triggered=self.select_directory, - icon=ima.icon('DirOpenIcon'), - tip=_("Select directory") - ) - fixed_dir_layout.addWidget(browse_btn) - wdir_layout.addLayout(fixed_dir_layout) - - # Checkbox to preserve the old behavior, i.e. always open the dialog - # on first run - self.firstrun_cb = QCheckBox(ALWAYS_OPEN_FIRST_RUN % _("this dialog")) - self.firstrun_cb.clicked.connect(self.set_firstrun_o) - self.firstrun_cb.setChecked(firstrun_o) - - layout = QVBoxLayout(self) - layout.addWidget(self.run_default_config_radio) - layout.addWidget(self.run_custom_config_radio) - layout.addWidget(interpreter_group) - layout.addWidget(common_group) - layout.addWidget(wdir_group) - layout.addWidget(self.firstrun_cb) - layout.addStretch(100) - - def select_directory(self): - """Select directory""" - basedir = str(self.wd_edit.text()) - if not osp.isdir(basedir): - basedir = getcwd_or_home() - directory = getexistingdirectory(self, _("Select directory"), basedir) - if directory: - self.wd_edit.setText(directory) - self.dir = directory - - def set(self, options): - self.runconf.set(options) - if self.runconf.default: - self.run_default_config_radio.setChecked(True) - else: - self.run_custom_config_radio.setChecked(True) - self.clo_cb.setChecked(self.runconf.args_enabled) - self.clo_edit.setText(self.runconf.args) - if self.runconf.current: - self.current_radio.setChecked(True) - elif self.runconf.systerm: - self.systerm_radio.setChecked(True) - else: - self.dedicated_radio.setChecked(True) - self.interact_cb.setChecked(self.runconf.interact) - self.post_mortem_cb.setChecked(self.runconf.post_mortem) - self.pclo_cb.setChecked(self.runconf.python_args_enabled) - self.pclo_edit.setText(self.runconf.python_args) - self.clear_var_cb.setChecked(self.runconf.clear_namespace) - self.console_ns_cb.setChecked(self.runconf.console_namespace) - self.file_dir_radio.setChecked(self.runconf.file_dir) - self.cwd_radio.setChecked(self.runconf.cw_dir) - self.fixed_dir_radio.setChecked(self.runconf.fixed_dir) - self.dir = self.runconf.dir - self.wd_edit.setText(self.dir) - - def get(self): - self.runconf.default = self.run_default_config_radio.isChecked() - self.runconf.args_enabled = self.clo_cb.isChecked() - self.runconf.args = str(self.clo_edit.text()) - self.runconf.current = self.current_radio.isChecked() - self.runconf.systerm = self.systerm_radio.isChecked() - self.runconf.interact = self.interact_cb.isChecked() - self.runconf.post_mortem = self.post_mortem_cb.isChecked() - self.runconf.python_args_enabled = self.pclo_cb.isChecked() - self.runconf.python_args = str(self.pclo_edit.text()) - self.runconf.clear_namespace = self.clear_var_cb.isChecked() - self.runconf.console_namespace = self.console_ns_cb.isChecked() - self.runconf.file_dir = self.file_dir_radio.isChecked() - self.runconf.cw_dir = self.cwd_radio.isChecked() - self.runconf.fixed_dir = self.fixed_dir_radio.isChecked() - self.runconf.dir = self.wd_edit.text() - return self.runconf.get() - - def is_valid(self): - wdir = str(self.wd_edit.text()) - if not self.fixed_dir_radio.isChecked() or osp.isdir(wdir): - return True - else: - QMessageBox.critical(self, _("Run configuration"), - _("The following working directory is " - "not valid:
    %s") % wdir) - return False - - def set_firstrun_o(self): - CONF.set('run', ALWAYS_OPEN_FIRST_RUN_OPTION, - self.firstrun_cb.isChecked()) - - -class BaseRunConfigDialog(QDialog): - """Run configuration dialog box, base widget""" - size_change = Signal(QSize) - - def __init__(self, parent=None): - QDialog.__init__(self, parent) - self.setWindowFlags( - self.windowFlags() & ~Qt.WindowContextHelpButtonHint) - - # 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.setWindowIcon(ima.icon('run_settings')) - layout = QVBoxLayout() - self.setLayout(layout) - - def add_widgets(self, *widgets_or_spacings): - """Add widgets/spacing to dialog vertical layout""" - layout = self.layout() - for widget_or_spacing in widgets_or_spacings: - if isinstance(widget_or_spacing, int): - layout.addSpacing(widget_or_spacing) - else: - layout.addWidget(widget_or_spacing) - return layout - - def add_button_box(self, stdbtns): - """Create dialog button box and add it to the dialog layout""" - bbox = QDialogButtonBox(stdbtns) - run_btn = bbox.addButton(_("Run"), QDialogButtonBox.AcceptRole) - run_btn.clicked.connect(self.run_btn_clicked) - bbox.accepted.connect(self.accept) - bbox.rejected.connect(self.reject) - btnlayout = QHBoxLayout() - btnlayout.addStretch(1) - btnlayout.addWidget(bbox) - self.layout().addLayout(btnlayout) - - def resizeEvent(self, event): - """ - Reimplement Qt method to be able to save the widget's size from the - main application - """ - QDialog.resizeEvent(self, event) - self.size_change.emit(self.size()) - - def run_btn_clicked(self): - """Run button was just clicked""" - pass - - def setup(self, fname): - """Setup Run Configuration dialog with filename *fname*""" - raise NotImplementedError - - -class RunConfigOneDialog(BaseRunConfigDialog): - """Run configuration dialog box: single file version""" - - def __init__(self, parent=None): - BaseRunConfigDialog.__init__(self, parent) - self.filename = None - self.runconfigoptions = None - - def setup(self, fname): - """Setup Run Configuration dialog with filename *fname*""" - self.filename = fname - self.runconfigoptions = RunConfigOptions(self) - self.runconfigoptions.set(RunConfiguration(fname).get()) - scrollarea = QScrollArea(self) - scrollarea.setWidget(self.runconfigoptions) - scrollarea.setMinimumWidth(560) - scrollarea.setWidgetResizable(True) - self.add_widgets(scrollarea) - self.add_button_box(QDialogButtonBox.Cancel) - self.setWindowTitle(_("Run settings for %s") % osp.basename(fname)) - - @Slot() - def accept(self): - """Reimplement Qt method""" - if not self.runconfigoptions.is_valid(): - return - configurations = _get_run_configurations() - configurations.insert(0, (self.filename, self.runconfigoptions.get())) - _set_run_configurations(configurations) - QDialog.accept(self) - - def get_configuration(self): - # It is import to avoid accessing Qt C++ object as it has probably - # already been destroyed, due to the Qt.WA_DeleteOnClose attribute - return self.runconfigoptions.runconf - - -class RunConfigDialog(BaseRunConfigDialog): - """Run configuration dialog box: multiple file version""" - - def __init__(self, parent=None): - BaseRunConfigDialog.__init__(self, parent) - self.file_to_run = None - self.combo = None - self.stack = None - - def run_btn_clicked(self): - """Run button was just clicked""" - self.file_to_run = str(self.combo.currentText()) - - def setup(self, fname): - """Setup Run Configuration dialog with filename *fname*""" - combo_label = QLabel(_("Select a run configuration:")) - self.combo = QComboBox() - self.combo.setMaxVisibleItems(20) - - self.stack = QStackedWidget() - - configurations = _get_run_configurations() - for index, (filename, options) in enumerate(configurations): - if fname == filename: - break - else: - # There is no run configuration for script *fname*: - # creating a temporary configuration that will be kept only if - # dialog changes are accepted by the user - configurations.insert(0, (fname, RunConfiguration(fname).get())) - index = 0 - for filename, options in configurations: - widget = RunConfigOptions(self) - widget.set(options) - widget.layout().setContentsMargins(0, 0, 0, 0) - self.combo.addItem(filename) - self.stack.addWidget(widget) - self.combo.currentIndexChanged.connect(self.stack.setCurrentIndex) - self.combo.setCurrentIndex(index) - - layout = self.add_widgets(combo_label, self.combo, 10, self.stack) - widget_dialog = QWidget() - widget_dialog.setLayout(layout) - scrollarea = QScrollArea(self) - scrollarea.setWidget(widget_dialog) - scrollarea.setMinimumWidth(600) - scrollarea.setWidgetResizable(True) - scroll_layout = QVBoxLayout(self) - scroll_layout.addWidget(scrollarea) - self.add_button_box(QDialogButtonBox.Ok | QDialogButtonBox.Cancel) - - self.setWindowTitle(_("Run configuration per file")) - - def accept(self): - """Reimplement Qt method""" - configurations = [] - for index in range(self.stack.count()): - filename = str(self.combo.itemText(index)) - runconfigoptions = self.stack.widget(index) - if index == self.stack.currentIndex() and\ - not runconfigoptions.is_valid(): - return - options = runconfigoptions.get() - configurations.append( (filename, options) ) - _set_run_configurations(configurations) - QDialog.accept(self) +# -*- coding: utf-8 -*- +# +# Copyright © Spyder Project Contributors +# Licensed under the terms of the MIT License +# (see spyder/__init__.py for details) + +"""Run dialogs and widgets and data models.""" + +# Standard library imports +import os.path as osp + +# Third party imports +from qtpy.compat import getexistingdirectory +from qtpy.QtCore import QSize, Qt, Signal, Slot +from qtpy.QtWidgets import (QCheckBox, QComboBox, QDialog, QDialogButtonBox, + QFrame, QGridLayout, QGroupBox, QHBoxLayout, + QLabel, QLineEdit, QMessageBox, QPushButton, + QRadioButton, QSizePolicy, QScrollArea, + QStackedWidget, QVBoxLayout, QWidget) + +# Local imports +from spyder.api.translations import get_translation +from spyder.config.manager import CONF +from spyder.utils.icon_manager import ima +from spyder.utils.misc import getcwd_or_home +from spyder.utils.qthelpers import create_toolbutton + +# Localization +_ = get_translation("spyder") + +RUN_DEFAULT_CONFIG = _("Run file with default configuration") +RUN_CUSTOM_CONFIG = _("Run file with custom configuration") +CURRENT_INTERPRETER = _("Execute in current console") +DEDICATED_INTERPRETER = _("Execute in a dedicated console") +SYSTERM_INTERPRETER = _("Execute in an external system terminal") + +CURRENT_INTERPRETER_OPTION = 'default/interpreter/current' +DEDICATED_INTERPRETER_OPTION = 'default/interpreter/dedicated' +SYSTERM_INTERPRETER_OPTION = 'default/interpreter/systerm' + +WDIR_USE_SCRIPT_DIR_OPTION = 'default/wdir/use_script_directory' +WDIR_USE_CWD_DIR_OPTION = 'default/wdir/use_cwd_directory' +WDIR_USE_FIXED_DIR_OPTION = 'default/wdir/use_fixed_directory' +WDIR_FIXED_DIR_OPTION = 'default/wdir/fixed_directory' + +ALWAYS_OPEN_FIRST_RUN = _("Always show %s on a first file run") +ALWAYS_OPEN_FIRST_RUN_OPTION = 'open_on_firstrun' + +CLEAR_ALL_VARIABLES = _("Remove all variables before execution") +CONSOLE_NAMESPACE = _("Run in console's namespace instead of an empty one") +POST_MORTEM = _("Directly enter debugging when errors appear") +INTERACT = _("Interact with the Python console after execution") + +FILE_DIR = _("The directory of the file being executed") +CW_DIR = _("The current working directory") +FIXED_DIR = _("The following directory:") + + +class RunConfiguration(object): + """Run configuration""" + + def __init__(self, fname=None): + self.default = None + self.args = None + self.args_enabled = None + self.wdir = None + self.wdir_enabled = None + self.current = None + self.systerm = None + self.interact = None + self.post_mortem = None + self.python_args = None + self.python_args_enabled = None + self.clear_namespace = None + self.console_namespace = None + self.file_dir = None + self.cw_dir = None + self.fixed_dir = None + self.dir = None + + self.set(CONF.get('run', 'defaultconfiguration', default={})) + + def set(self, options): + self.default = options.get('default', True) + self.args = options.get('args', '') + self.args_enabled = options.get('args/enabled', False) + self.current = options.get('current', + CONF.get('run', CURRENT_INTERPRETER_OPTION, True)) + self.systerm = options.get('systerm', + CONF.get('run', SYSTERM_INTERPRETER_OPTION, False)) + self.interact = options.get('interact', + CONF.get('run', 'interact', False)) + self.post_mortem = options.get('post_mortem', + CONF.get('run', 'post_mortem', False)) + self.python_args = options.get('python_args', '') + self.python_args_enabled = options.get('python_args/enabled', False) + self.clear_namespace = options.get('clear_namespace', + CONF.get('run', 'clear_namespace', False)) + self.console_namespace = options.get('console_namespace', + CONF.get('run', 'console_namespace', False)) + self.file_dir = options.get('file_dir', + CONF.get('run', WDIR_USE_SCRIPT_DIR_OPTION, True)) + self.cw_dir = options.get('cw_dir', + CONF.get('run', WDIR_USE_CWD_DIR_OPTION, False)) + self.fixed_dir = options.get('fixed_dir', + CONF.get('run', WDIR_USE_FIXED_DIR_OPTION, False)) + self.dir = options.get('dir', '') + + def get(self): + return { + 'default': self.default, + 'args/enabled': self.args_enabled, + 'args': self.args, + 'workdir/enabled': self.wdir_enabled, + 'workdir': self.wdir, + 'current': self.current, + 'systerm': self.systerm, + 'interact': self.interact, + 'post_mortem': self.post_mortem, + 'python_args/enabled': self.python_args_enabled, + 'python_args': self.python_args, + 'clear_namespace': self.clear_namespace, + 'console_namespace': self.console_namespace, + 'file_dir': self.file_dir, + 'cw_dir': self.cw_dir, + 'fixed_dir': self.fixed_dir, + 'dir': self.dir + } + + def get_working_directory(self): + return self.dir + + def get_arguments(self): + if self.args_enabled: + return self.args + else: + return '' + + def get_python_arguments(self): + if self.python_args_enabled: + return self.python_args + else: + return '' + + +def _get_run_configurations(): + history_count = CONF.get('run', 'history', 20) + try: + return [(filename, options) + for filename, options in CONF.get('run', 'configurations', []) + if osp.isfile(filename)][:history_count] + except ValueError: + CONF.set('run', 'configurations', []) + return [] + + +def _set_run_configurations(configurations): + history_count = CONF.get('run', 'history', 20) + CONF.set('run', 'configurations', configurations[:history_count]) + + +def get_run_configuration(fname): + """Return script *fname* run configuration""" + configurations = _get_run_configurations() + for filename, options in configurations: + if fname == filename: + runconf = RunConfiguration() + runconf.set(options) + return runconf + + +class RunConfigOptions(QWidget): + """Run configuration options""" + def __init__(self, parent=None): + QWidget.__init__(self, parent) + + self.dir = None + self.runconf = RunConfiguration() + firstrun_o = CONF.get('run', ALWAYS_OPEN_FIRST_RUN_OPTION, False) + + # --- Run settings --- + self.run_default_config_radio = QRadioButton(RUN_DEFAULT_CONFIG) + self.run_custom_config_radio = QRadioButton(RUN_CUSTOM_CONFIG) + + # --- Interpreter --- + interpreter_group = QGroupBox(_("Console")) + interpreter_group.setDisabled(True) + + self.run_custom_config_radio.toggled.connect( + interpreter_group.setEnabled) + + interpreter_layout = QVBoxLayout(interpreter_group) + + self.current_radio = QRadioButton(CURRENT_INTERPRETER) + interpreter_layout.addWidget(self.current_radio) + + self.dedicated_radio = QRadioButton(DEDICATED_INTERPRETER) + interpreter_layout.addWidget(self.dedicated_radio) + + self.systerm_radio = QRadioButton(SYSTERM_INTERPRETER) + interpreter_layout.addWidget(self.systerm_radio) + + # --- System terminal --- + external_group = QWidget() + external_group.setDisabled(True) + self.systerm_radio.toggled.connect(external_group.setEnabled) + + external_layout = QGridLayout() + external_group.setLayout(external_layout) + self.interact_cb = QCheckBox(INTERACT) + external_layout.addWidget(self.interact_cb, 1, 0, 1, -1) + + self.pclo_cb = QCheckBox(_("Command line options:")) + external_layout.addWidget(self.pclo_cb, 3, 0) + self.pclo_edit = QLineEdit() + self.pclo_cb.toggled.connect(self.pclo_edit.setEnabled) + self.pclo_edit.setEnabled(False) + self.pclo_edit.setToolTip(_("-u is added to the " + "other options you set here")) + external_layout.addWidget(self.pclo_edit, 3, 1) + + interpreter_layout.addWidget(external_group) + + # --- General settings ---- + common_group = QGroupBox(_("General settings")) + common_group.setDisabled(True) + + self.run_custom_config_radio.toggled.connect(common_group.setEnabled) + + common_layout = QGridLayout(common_group) + + self.clear_var_cb = QCheckBox(CLEAR_ALL_VARIABLES) + common_layout.addWidget(self.clear_var_cb, 0, 0) + + self.console_ns_cb = QCheckBox(CONSOLE_NAMESPACE) + common_layout.addWidget(self.console_ns_cb, 1, 0) + + self.post_mortem_cb = QCheckBox(POST_MORTEM) + common_layout.addWidget(self.post_mortem_cb, 2, 0) + + self.clo_cb = QCheckBox(_("Command line options:")) + common_layout.addWidget(self.clo_cb, 3, 0) + self.clo_edit = QLineEdit() + self.clo_cb.toggled.connect(self.clo_edit.setEnabled) + self.clo_edit.setEnabled(False) + common_layout.addWidget(self.clo_edit, 3, 1) + + # --- Working directory --- + wdir_group = QGroupBox(_("Working directory settings")) + wdir_group.setDisabled(True) + + self.run_custom_config_radio.toggled.connect(wdir_group.setEnabled) + + wdir_layout = QVBoxLayout(wdir_group) + + self.file_dir_radio = QRadioButton(FILE_DIR) + wdir_layout.addWidget(self.file_dir_radio) + + self.cwd_radio = QRadioButton(CW_DIR) + wdir_layout.addWidget(self.cwd_radio) + + fixed_dir_layout = QHBoxLayout() + self.fixed_dir_radio = QRadioButton(FIXED_DIR) + fixed_dir_layout.addWidget(self.fixed_dir_radio) + self.wd_edit = QLineEdit() + self.fixed_dir_radio.toggled.connect(self.wd_edit.setEnabled) + self.wd_edit.setEnabled(False) + fixed_dir_layout.addWidget(self.wd_edit) + browse_btn = create_toolbutton( + self, + triggered=self.select_directory, + icon=ima.icon('DirOpenIcon'), + tip=_("Select directory") + ) + fixed_dir_layout.addWidget(browse_btn) + wdir_layout.addLayout(fixed_dir_layout) + + # Checkbox to preserve the old behavior, i.e. always open the dialog + # on first run + self.firstrun_cb = QCheckBox(ALWAYS_OPEN_FIRST_RUN % _("this dialog")) + self.firstrun_cb.clicked.connect(self.set_firstrun_o) + self.firstrun_cb.setChecked(firstrun_o) + + layout = QVBoxLayout(self) + layout.addWidget(self.run_default_config_radio) + layout.addWidget(self.run_custom_config_radio) + layout.addWidget(interpreter_group) + layout.addWidget(common_group) + layout.addWidget(wdir_group) + layout.addWidget(self.firstrun_cb) + layout.addStretch(100) + + def select_directory(self): + """Select directory""" + basedir = str(self.wd_edit.text()) + if not osp.isdir(basedir): + basedir = getcwd_or_home() + directory = getexistingdirectory(self, _("Select directory"), basedir) + if directory: + self.wd_edit.setText(directory) + self.dir = directory + + def set(self, options): + self.runconf.set(options) + if self.runconf.default: + self.run_default_config_radio.setChecked(True) + else: + self.run_custom_config_radio.setChecked(True) + self.clo_cb.setChecked(self.runconf.args_enabled) + self.clo_edit.setText(self.runconf.args) + if self.runconf.current: + self.current_radio.setChecked(True) + elif self.runconf.systerm: + self.systerm_radio.setChecked(True) + else: + self.dedicated_radio.setChecked(True) + self.interact_cb.setChecked(self.runconf.interact) + self.post_mortem_cb.setChecked(self.runconf.post_mortem) + self.pclo_cb.setChecked(self.runconf.python_args_enabled) + self.pclo_edit.setText(self.runconf.python_args) + self.clear_var_cb.setChecked(self.runconf.clear_namespace) + self.console_ns_cb.setChecked(self.runconf.console_namespace) + self.file_dir_radio.setChecked(self.runconf.file_dir) + self.cwd_radio.setChecked(self.runconf.cw_dir) + self.fixed_dir_radio.setChecked(self.runconf.fixed_dir) + self.dir = self.runconf.dir + self.wd_edit.setText(self.dir) + + def get(self): + self.runconf.default = self.run_default_config_radio.isChecked() + self.runconf.args_enabled = self.clo_cb.isChecked() + self.runconf.args = str(self.clo_edit.text()) + self.runconf.current = self.current_radio.isChecked() + self.runconf.systerm = self.systerm_radio.isChecked() + self.runconf.interact = self.interact_cb.isChecked() + self.runconf.post_mortem = self.post_mortem_cb.isChecked() + self.runconf.python_args_enabled = self.pclo_cb.isChecked() + self.runconf.python_args = str(self.pclo_edit.text()) + self.runconf.clear_namespace = self.clear_var_cb.isChecked() + self.runconf.console_namespace = self.console_ns_cb.isChecked() + self.runconf.file_dir = self.file_dir_radio.isChecked() + self.runconf.cw_dir = self.cwd_radio.isChecked() + self.runconf.fixed_dir = self.fixed_dir_radio.isChecked() + self.runconf.dir = self.wd_edit.text() + return self.runconf.get() + + def is_valid(self): + wdir = str(self.wd_edit.text()) + if not self.fixed_dir_radio.isChecked() or osp.isdir(wdir): + return True + else: + QMessageBox.critical(self, _("Run configuration"), + _("The following working directory is " + "not valid:
    %s") % wdir) + return False + + def set_firstrun_o(self): + CONF.set('run', ALWAYS_OPEN_FIRST_RUN_OPTION, + self.firstrun_cb.isChecked()) + + +class BaseRunConfigDialog(QDialog): + """Run configuration dialog box, base widget""" + size_change = Signal(QSize) + + def __init__(self, parent=None): + QDialog.__init__(self, parent) + self.setWindowFlags( + self.windowFlags() & ~Qt.WindowContextHelpButtonHint) + + # 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.setWindowIcon(ima.icon('run_settings')) + layout = QVBoxLayout() + self.setLayout(layout) + + def add_widgets(self, *widgets_or_spacings): + """Add widgets/spacing to dialog vertical layout""" + layout = self.layout() + for widget_or_spacing in widgets_or_spacings: + if isinstance(widget_or_spacing, int): + layout.addSpacing(widget_or_spacing) + else: + layout.addWidget(widget_or_spacing) + return layout + + def add_button_box(self, stdbtns): + """Create dialog button box and add it to the dialog layout""" + bbox = QDialogButtonBox(stdbtns) + run_btn = bbox.addButton(_("Run"), QDialogButtonBox.AcceptRole) + run_btn.clicked.connect(self.run_btn_clicked) + bbox.accepted.connect(self.accept) + bbox.rejected.connect(self.reject) + btnlayout = QHBoxLayout() + btnlayout.addStretch(1) + btnlayout.addWidget(bbox) + self.layout().addLayout(btnlayout) + + def resizeEvent(self, event): + """ + Reimplement Qt method to be able to save the widget's size from the + main application + """ + QDialog.resizeEvent(self, event) + self.size_change.emit(self.size()) + + def run_btn_clicked(self): + """Run button was just clicked""" + pass + + def setup(self, fname): + """Setup Run Configuration dialog with filename *fname*""" + raise NotImplementedError + + +class RunConfigOneDialog(BaseRunConfigDialog): + """Run configuration dialog box: single file version""" + + def __init__(self, parent=None): + BaseRunConfigDialog.__init__(self, parent) + self.filename = None + self.runconfigoptions = None + + def setup(self, fname): + """Setup Run Configuration dialog with filename *fname*""" + self.filename = fname + self.runconfigoptions = RunConfigOptions(self) + self.runconfigoptions.set(RunConfiguration(fname).get()) + scrollarea = QScrollArea(self) + scrollarea.setWidget(self.runconfigoptions) + scrollarea.setMinimumWidth(560) + scrollarea.setWidgetResizable(True) + self.add_widgets(scrollarea) + self.add_button_box(QDialogButtonBox.Cancel) + self.setWindowTitle(_("Run settings for %s") % osp.basename(fname)) + + @Slot() + def accept(self): + """Reimplement Qt method""" + if not self.runconfigoptions.is_valid(): + return + configurations = _get_run_configurations() + configurations.insert(0, (self.filename, self.runconfigoptions.get())) + _set_run_configurations(configurations) + QDialog.accept(self) + + def get_configuration(self): + # It is import to avoid accessing Qt C++ object as it has probably + # already been destroyed, due to the Qt.WA_DeleteOnClose attribute + return self.runconfigoptions.runconf + + +class RunConfigDialog(BaseRunConfigDialog): + """Run configuration dialog box: multiple file version""" + + def __init__(self, parent=None): + BaseRunConfigDialog.__init__(self, parent) + self.file_to_run = None + self.combo = None + self.stack = None + + def run_btn_clicked(self): + """Run button was just clicked""" + self.file_to_run = str(self.combo.currentText()) + + def setup(self, fname): + """Setup Run Configuration dialog with filename *fname*""" + combo_label = QLabel(_("Select a run configuration:")) + self.combo = QComboBox() + self.combo.setMaxVisibleItems(20) + + self.stack = QStackedWidget() + + configurations = _get_run_configurations() + for index, (filename, options) in enumerate(configurations): + if fname == filename: + break + else: + # There is no run configuration for script *fname*: + # creating a temporary configuration that will be kept only if + # dialog changes are accepted by the user + configurations.insert(0, (fname, RunConfiguration(fname).get())) + index = 0 + for filename, options in configurations: + widget = RunConfigOptions(self) + widget.set(options) + widget.layout().setContentsMargins(0, 0, 0, 0) + self.combo.addItem(filename) + self.stack.addWidget(widget) + self.combo.currentIndexChanged.connect(self.stack.setCurrentIndex) + self.combo.setCurrentIndex(index) + + layout = self.add_widgets(combo_label, self.combo, 10, self.stack) + widget_dialog = QWidget() + widget_dialog.setLayout(layout) + scrollarea = QScrollArea(self) + scrollarea.setWidget(widget_dialog) + scrollarea.setMinimumWidth(600) + scrollarea.setWidgetResizable(True) + scroll_layout = QVBoxLayout(self) + scroll_layout.addWidget(scrollarea) + self.add_button_box(QDialogButtonBox.Ok | QDialogButtonBox.Cancel) + + self.setWindowTitle(_("Run configuration per file")) + + def accept(self): + """Reimplement Qt method""" + configurations = [] + for index in range(self.stack.count()): + filename = str(self.combo.itemText(index)) + runconfigoptions = self.stack.widget(index) + if index == self.stack.currentIndex() and\ + not runconfigoptions.is_valid(): + return + options = runconfigoptions.get() + configurations.append( (filename, options) ) + _set_run_configurations(configurations) + QDialog.accept(self) diff --git a/spyder/plugins/shortcuts/__init__.py b/spyder/plugins/shortcuts/__init__.py index 9b9fc59c0b0..105d8a94af6 100644 --- a/spyder/plugins/shortcuts/__init__.py +++ b/spyder/plugins/shortcuts/__init__.py @@ -1,12 +1,12 @@ -# -*- coding: utf-8 -*- -# -# Copyright © Spyder Project Contributors -# Licensed under the terms of the MIT License -# (see spyder/__init__.py for details) - -""" -spyder.plugins.shortcuts -======================== - -Shortcuts Plugin. -""" +# -*- coding: utf-8 -*- +# +# Copyright © Spyder Project Contributors +# Licensed under the terms of the MIT License +# (see spyder/__init__.py for details) + +""" +spyder.plugins.shortcuts +======================== + +Shortcuts Plugin. +""" diff --git a/spyder/plugins/shortcuts/api.py b/spyder/plugins/shortcuts/api.py index c6c22acd43d..33bc7f3919f 100644 --- a/spyder/plugins/shortcuts/api.py +++ b/spyder/plugins/shortcuts/api.py @@ -1,7 +1,7 @@ -# -*- coding: utf-8 -*- -# -# Copyright © Spyder Project Contributors -# Licensed under the terms of the MIT License -# (see spyder/__init__.py for details) - -"""Shortcut API.""" +# -*- coding: utf-8 -*- +# +# Copyright © Spyder Project Contributors +# Licensed under the terms of the MIT License +# (see spyder/__init__.py for details) + +"""Shortcut API.""" diff --git a/spyder/plugins/shortcuts/confpage.py b/spyder/plugins/shortcuts/confpage.py index fa991cf2134..2e9552a058d 100644 --- a/spyder/plugins/shortcuts/confpage.py +++ b/spyder/plugins/shortcuts/confpage.py @@ -1,95 +1,95 @@ -# -*- coding: utf-8 -*- -# -# Copyright © Spyder Project Contributors -# Licensed under the terms of the MIT License -# (see spyder/__init__.py for details) - -"""Shortcut configuration page.""" - -# Standard library imports -import re - -# Third party imports -from qtpy import PYQT5 -from qtpy.QtWidgets import (QHBoxLayout, QLabel, QMessageBox, QPushButton, - QVBoxLayout) - -# Local imports -from spyder.api.preferences import PluginConfigPage -from spyder.api.translations import get_translation -from spyder.plugins.shortcuts.widgets.table import (ShortcutFinder, - ShortcutsTable) -from spyder.utils.icon_manager import ima - -# Localization -_ = get_translation('spyder') - - -class ShortcutsConfigPage(PluginConfigPage): - APPLY_CONF_PAGE_SETTINGS = True - - def setup_page(self): - # Widgets - self.table = ShortcutsTable(self, text_color=ima.MAIN_FG_COLOR) - self.finder = ShortcutFinder(self.table, self.table.set_regex) - self.label_finder = QLabel(_('Search: ')) - self.reset_btn = QPushButton(_("Reset to default values")) - self.top_label = QLabel( - _("Here you can browse the list of all available shortcuts in " - "Spyder. You can also customize them by double-clicking on any " - "entry in this table.")) - - # Widget setup - self.table.finder = self.finder - self.table.set_shortcut_data(self.plugin.get_shortcut_data()) - self.table.load_shortcuts() - self.table.finder.setPlaceholderText( - _("Search for a shortcut in the table above")) - self.top_label.setWordWrap(True) - - # Layout - hlayout = QHBoxLayout() - vlayout = QVBoxLayout() - vlayout.addWidget(self.top_label) - hlayout.addWidget(self.label_finder) - hlayout.addWidget(self.finder) - vlayout.addWidget(self.table) - vlayout.addLayout(hlayout) - vlayout.addWidget(self.reset_btn) - self.setLayout(vlayout) - - self.setTabOrder(self.table, self.finder) - self.setTabOrder(self.finder, self.reset_btn) - - # Signals - self.table.proxy_model.dataChanged.connect( - lambda i1, i2, roles, opt='', sect='': self.has_been_modified( - sect, opt)) - self.reset_btn.clicked.connect(self.reset_to_default) - - def check_settings(self): - self.table.check_shortcuts() - - def reset_to_default(self, force=False): - """Reset to default values of the shortcuts making a confirmation.""" - if not force: - reset = QMessageBox.warning( - self, - _("Shortcuts reset"), - _("Do you want to reset to default values?"), - QMessageBox.Yes | QMessageBox.No, - ) - - if reset == QMessageBox.No: - return - - self.plugin.reset_shortcuts() - self.plugin.apply_shortcuts() - self.table.load_shortcuts() - self.load_from_conf() - self.set_modified(False) - - def apply_settings(self, options): - self.table.save_shortcuts() - self.plugin.apply_shortcuts() - self.plugin.apply_conf(options) +# -*- coding: utf-8 -*- +# +# Copyright © Spyder Project Contributors +# Licensed under the terms of the MIT License +# (see spyder/__init__.py for details) + +"""Shortcut configuration page.""" + +# Standard library imports +import re + +# Third party imports +from qtpy import PYQT5 +from qtpy.QtWidgets import (QHBoxLayout, QLabel, QMessageBox, QPushButton, + QVBoxLayout) + +# Local imports +from spyder.api.preferences import PluginConfigPage +from spyder.api.translations import get_translation +from spyder.plugins.shortcuts.widgets.table import (ShortcutFinder, + ShortcutsTable) +from spyder.utils.icon_manager import ima + +# Localization +_ = get_translation('spyder') + + +class ShortcutsConfigPage(PluginConfigPage): + APPLY_CONF_PAGE_SETTINGS = True + + def setup_page(self): + # Widgets + self.table = ShortcutsTable(self, text_color=ima.MAIN_FG_COLOR) + self.finder = ShortcutFinder(self.table, self.table.set_regex) + self.label_finder = QLabel(_('Search: ')) + self.reset_btn = QPushButton(_("Reset to default values")) + self.top_label = QLabel( + _("Here you can browse the list of all available shortcuts in " + "Spyder. You can also customize them by double-clicking on any " + "entry in this table.")) + + # Widget setup + self.table.finder = self.finder + self.table.set_shortcut_data(self.plugin.get_shortcut_data()) + self.table.load_shortcuts() + self.table.finder.setPlaceholderText( + _("Search for a shortcut in the table above")) + self.top_label.setWordWrap(True) + + # Layout + hlayout = QHBoxLayout() + vlayout = QVBoxLayout() + vlayout.addWidget(self.top_label) + hlayout.addWidget(self.label_finder) + hlayout.addWidget(self.finder) + vlayout.addWidget(self.table) + vlayout.addLayout(hlayout) + vlayout.addWidget(self.reset_btn) + self.setLayout(vlayout) + + self.setTabOrder(self.table, self.finder) + self.setTabOrder(self.finder, self.reset_btn) + + # Signals + self.table.proxy_model.dataChanged.connect( + lambda i1, i2, roles, opt='', sect='': self.has_been_modified( + sect, opt)) + self.reset_btn.clicked.connect(self.reset_to_default) + + def check_settings(self): + self.table.check_shortcuts() + + def reset_to_default(self, force=False): + """Reset to default values of the shortcuts making a confirmation.""" + if not force: + reset = QMessageBox.warning( + self, + _("Shortcuts reset"), + _("Do you want to reset to default values?"), + QMessageBox.Yes | QMessageBox.No, + ) + + if reset == QMessageBox.No: + return + + self.plugin.reset_shortcuts() + self.plugin.apply_shortcuts() + self.table.load_shortcuts() + self.load_from_conf() + self.set_modified(False) + + def apply_settings(self, options): + self.table.save_shortcuts() + self.plugin.apply_shortcuts() + self.plugin.apply_conf(options) diff --git a/spyder/plugins/shortcuts/plugin.py b/spyder/plugins/shortcuts/plugin.py index 3b86b4c3044..9364461a7b6 100644 --- a/spyder/plugins/shortcuts/plugin.py +++ b/spyder/plugins/shortcuts/plugin.py @@ -1,248 +1,248 @@ -# -*- coding: utf-8 -*- -# ----------------------------------------------------------------------------- -# Copyright (c) 2009- Spyder Project Contributors -# -# Distributed under the terms of the MIT License -# (see spyder/__init__.py for details) -# ----------------------------------------------------------------------------- - -""" -Shortcuts Plugin. -""" - -# Standard library imports -import configparser -import sys - -# Third party imports -from qtpy.QtCore import Qt, Signal -from qtpy.QtGui import QKeySequence -from qtpy.QtWidgets import QAction, QShortcut - -# Local imports -from spyder.api.plugins import Plugins, SpyderPluginV2 -from spyder.api.plugin_registration.decorators import ( - on_plugin_available, on_plugin_teardown) -from spyder.api.translations import get_translation -from spyder.plugins.mainmenu.api import ApplicationMenus, HelpMenuSections -from spyder.plugins.shortcuts.confpage import ShortcutsConfigPage -from spyder.plugins.shortcuts.widgets.summary import ShortcutsSummaryDialog -from spyder.utils.qthelpers import add_shortcut_to_tooltip, SpyderAction - -# Localization -_ = get_translation('spyder') - - -class ShortcutActions: - ShortcutSummaryAction = "show_shortcut_summary_action" - - -# --- Plugin -# ---------------------------------------------------------------------------- -class Shortcuts(SpyderPluginV2): - """ - Shortcuts Plugin. - """ - - NAME = 'shortcuts' - # TODO: Fix requires to reflect the desired order in the preferences - REQUIRES = [Plugins.Preferences] - OPTIONAL = [Plugins.MainMenu] - CONF_WIDGET_CLASS = ShortcutsConfigPage - CONF_SECTION = NAME - CONF_FILE = False - CAN_BE_DISABLED = False - - # --- Signals - # ------------------------------------------------------------------------ - sig_shortcuts_updated = Signal() - """ - This signal is emitted to inform shortcuts have been updated. - """ - - # --- SpyderPluginV2 API - # ------------------------------------------------------------------------ - @staticmethod - def get_name(): - return _("Keyboard shortcuts") - - def get_description(self): - return _("Manage application, widget and actions shortcuts.") - - def get_icon(self): - return self.create_icon('keyboard') - - def on_initialize(self): - self._shortcut_data = [] - self.create_action( - ShortcutActions.ShortcutSummaryAction, - text=_("Shortcuts Summary"), - triggered=lambda: self.show_summary(), - register_shortcut=True, - context=Qt.ApplicationShortcut, - ) - - @on_plugin_available(plugin=Plugins.Preferences) - def on_preferences_available(self): - preferences = self.get_plugin(Plugins.Preferences) - preferences.register_plugin_preferences(self) - - @on_plugin_available(plugin=Plugins.MainMenu) - def on_main_menu_available(self): - mainmenu = self.get_plugin(Plugins.MainMenu) - shortcuts_action = self.get_action( - ShortcutActions.ShortcutSummaryAction) - - # Add to Help menu. - mainmenu.add_item_to_application_menu( - shortcuts_action, - menu_id=ApplicationMenus.Help, - section=HelpMenuSections.Documentation, - ) - - @on_plugin_teardown(plugin=Plugins.Preferences) - def on_preferences_teardown(self): - preferences = self.get_plugin(Plugins.Preferences) - preferences.deregister_plugin_preferences(self) - - @on_plugin_teardown(plugin=Plugins.MainMenu) - def on_main_menu_teardown(self): - mainmenu = self.get_plugin(Plugins.MainMenu) - mainmenu.remove_item_from_application_menu( - ShortcutActions.ShortcutSummaryAction, - menu_id=ApplicationMenus.Help - ) - - def on_mainwindow_visible(self): - self.apply_shortcuts() - - # --- Public API - # ------------------------------------------------------------------------ - def get_shortcut_data(self): - """ - Return the registered shortcut data from the main application window. - """ - return self._shortcut_data - - def reset_shortcuts(self): - """Reset shrotcuts.""" - if self._conf: - self._conf.reset_shortcuts() - - def show_summary(self): - """Reset shortcuts.""" - dlg = ShortcutsSummaryDialog(None) - dlg.exec_() - - def register_shortcut(self, qaction_or_qshortcut, context, name, - add_shortcut_to_tip=True, plugin_name=None): - """ - Register QAction or QShortcut to Spyder main application, - with shortcut (context, name, default) - """ - self._shortcut_data.append((qaction_or_qshortcut, context, - name, add_shortcut_to_tip, plugin_name)) - - def unregister_shortcut(self, qaction_or_qshortcut, context, name, - add_shortcut_to_tip=True, plugin_name=None): - """ - Unregister QAction or QShortcut from Spyder main application. - """ - data = (qaction_or_qshortcut, context, name, add_shortcut_to_tip, - plugin_name) - - if data in self._shortcut_data: - self._shortcut_data.remove(data) - - def apply_shortcuts(self): - """ - Apply shortcuts settings to all widgets/plugins. - """ - toberemoved = [] - - # TODO: Check shortcut existence based on action existence, so that we - # can update shortcut names without showing the old ones on the - # preferences - for index, (qobject, context, name, add_shortcut_to_tip, - plugin_name) in enumerate(self._shortcut_data): - try: - shortcut_sequence = self.get_shortcut(context, name, - plugin_name) - except (configparser.NoSectionError, configparser.NoOptionError): - # If shortcut does not exist, save it to CONF. This is an - # action for which there is no shortcut assigned (yet) in - # the configuration - self.set_shortcut(context, name, '', plugin_name) - shortcut_sequence = '' - - if shortcut_sequence: - keyseq = QKeySequence(shortcut_sequence) - else: - # Needed to remove old sequences that were cleared. - # See spyder-ide/spyder#12992 - keyseq = QKeySequence() - - # Do not register shortcuts for the toggle view action. - # The shortcut will be displayed only on the menus and handled by - # about to show/hide signals. - if (name.startswith('switch to') - and isinstance(qobject, SpyderAction)): - keyseq = QKeySequence() - - try: - if isinstance(qobject, QAction): - if (sys.platform == 'darwin' - and qobject._shown_shortcut == 'missing'): - qobject._shown_shortcut = keyseq - else: - qobject.setShortcut(keyseq) - - if add_shortcut_to_tip: - add_shortcut_to_tooltip(qobject, context, name) - elif isinstance(qobject, QShortcut): - qobject.setKey(keyseq) - except RuntimeError: - # Object has been deleted - toberemoved.append(index) - - for index in sorted(toberemoved, reverse=True): - self._shortcut_data.pop(index) - - self.sig_shortcuts_updated.emit() - - def get_shortcut(self, context, name, plugin_name=None): - """ - Get keyboard shortcut (key sequence string). - - Parameters - ---------- - context: - Context must be either '_' for global or the name of a plugin. - name: str - Name of the shortcut. - plugin_id: spyder.api.plugins.SpyderpluginV2 or None - The plugin for which the shortcut is registered. Default is None. - - Returns - ------- - Shortcut - A shortcut object. - """ - return self._conf.get_shortcut(context, name, plugin_name=plugin_name) - - def set_shortcut(self, context, name, keystr, plugin_id=None): - """ - Set keyboard shortcut (key sequence string). - - Parameters - ---------- - context: - Context must be either '_' for global or the name of a plugin. - name: str - Name of the shortcut. - keystr: str - Shortcut keys in string form. - plugin_id: spyder.api.plugins.SpyderpluginV2 or None - The plugin for which the shortcut is registered. Default is None. - """ - self._conf.set_shortcut(context, name, keystr, plugin_name=plugin_id) +# -*- coding: utf-8 -*- +# ----------------------------------------------------------------------------- +# Copyright (c) 2009- Spyder Project Contributors +# +# Distributed under the terms of the MIT License +# (see spyder/__init__.py for details) +# ----------------------------------------------------------------------------- + +""" +Shortcuts Plugin. +""" + +# Standard library imports +import configparser +import sys + +# Third party imports +from qtpy.QtCore import Qt, Signal +from qtpy.QtGui import QKeySequence +from qtpy.QtWidgets import QAction, QShortcut + +# Local imports +from spyder.api.plugins import Plugins, SpyderPluginV2 +from spyder.api.plugin_registration.decorators import ( + on_plugin_available, on_plugin_teardown) +from spyder.api.translations import get_translation +from spyder.plugins.mainmenu.api import ApplicationMenus, HelpMenuSections +from spyder.plugins.shortcuts.confpage import ShortcutsConfigPage +from spyder.plugins.shortcuts.widgets.summary import ShortcutsSummaryDialog +from spyder.utils.qthelpers import add_shortcut_to_tooltip, SpyderAction + +# Localization +_ = get_translation('spyder') + + +class ShortcutActions: + ShortcutSummaryAction = "show_shortcut_summary_action" + + +# --- Plugin +# ---------------------------------------------------------------------------- +class Shortcuts(SpyderPluginV2): + """ + Shortcuts Plugin. + """ + + NAME = 'shortcuts' + # TODO: Fix requires to reflect the desired order in the preferences + REQUIRES = [Plugins.Preferences] + OPTIONAL = [Plugins.MainMenu] + CONF_WIDGET_CLASS = ShortcutsConfigPage + CONF_SECTION = NAME + CONF_FILE = False + CAN_BE_DISABLED = False + + # --- Signals + # ------------------------------------------------------------------------ + sig_shortcuts_updated = Signal() + """ + This signal is emitted to inform shortcuts have been updated. + """ + + # --- SpyderPluginV2 API + # ------------------------------------------------------------------------ + @staticmethod + def get_name(): + return _("Keyboard shortcuts") + + def get_description(self): + return _("Manage application, widget and actions shortcuts.") + + def get_icon(self): + return self.create_icon('keyboard') + + def on_initialize(self): + self._shortcut_data = [] + self.create_action( + ShortcutActions.ShortcutSummaryAction, + text=_("Shortcuts Summary"), + triggered=lambda: self.show_summary(), + register_shortcut=True, + context=Qt.ApplicationShortcut, + ) + + @on_plugin_available(plugin=Plugins.Preferences) + def on_preferences_available(self): + preferences = self.get_plugin(Plugins.Preferences) + preferences.register_plugin_preferences(self) + + @on_plugin_available(plugin=Plugins.MainMenu) + def on_main_menu_available(self): + mainmenu = self.get_plugin(Plugins.MainMenu) + shortcuts_action = self.get_action( + ShortcutActions.ShortcutSummaryAction) + + # Add to Help menu. + mainmenu.add_item_to_application_menu( + shortcuts_action, + menu_id=ApplicationMenus.Help, + section=HelpMenuSections.Documentation, + ) + + @on_plugin_teardown(plugin=Plugins.Preferences) + def on_preferences_teardown(self): + preferences = self.get_plugin(Plugins.Preferences) + preferences.deregister_plugin_preferences(self) + + @on_plugin_teardown(plugin=Plugins.MainMenu) + def on_main_menu_teardown(self): + mainmenu = self.get_plugin(Plugins.MainMenu) + mainmenu.remove_item_from_application_menu( + ShortcutActions.ShortcutSummaryAction, + menu_id=ApplicationMenus.Help + ) + + def on_mainwindow_visible(self): + self.apply_shortcuts() + + # --- Public API + # ------------------------------------------------------------------------ + def get_shortcut_data(self): + """ + Return the registered shortcut data from the main application window. + """ + return self._shortcut_data + + def reset_shortcuts(self): + """Reset shrotcuts.""" + if self._conf: + self._conf.reset_shortcuts() + + def show_summary(self): + """Reset shortcuts.""" + dlg = ShortcutsSummaryDialog(None) + dlg.exec_() + + def register_shortcut(self, qaction_or_qshortcut, context, name, + add_shortcut_to_tip=True, plugin_name=None): + """ + Register QAction or QShortcut to Spyder main application, + with shortcut (context, name, default) + """ + self._shortcut_data.append((qaction_or_qshortcut, context, + name, add_shortcut_to_tip, plugin_name)) + + def unregister_shortcut(self, qaction_or_qshortcut, context, name, + add_shortcut_to_tip=True, plugin_name=None): + """ + Unregister QAction or QShortcut from Spyder main application. + """ + data = (qaction_or_qshortcut, context, name, add_shortcut_to_tip, + plugin_name) + + if data in self._shortcut_data: + self._shortcut_data.remove(data) + + def apply_shortcuts(self): + """ + Apply shortcuts settings to all widgets/plugins. + """ + toberemoved = [] + + # TODO: Check shortcut existence based on action existence, so that we + # can update shortcut names without showing the old ones on the + # preferences + for index, (qobject, context, name, add_shortcut_to_tip, + plugin_name) in enumerate(self._shortcut_data): + try: + shortcut_sequence = self.get_shortcut(context, name, + plugin_name) + except (configparser.NoSectionError, configparser.NoOptionError): + # If shortcut does not exist, save it to CONF. This is an + # action for which there is no shortcut assigned (yet) in + # the configuration + self.set_shortcut(context, name, '', plugin_name) + shortcut_sequence = '' + + if shortcut_sequence: + keyseq = QKeySequence(shortcut_sequence) + else: + # Needed to remove old sequences that were cleared. + # See spyder-ide/spyder#12992 + keyseq = QKeySequence() + + # Do not register shortcuts for the toggle view action. + # The shortcut will be displayed only on the menus and handled by + # about to show/hide signals. + if (name.startswith('switch to') + and isinstance(qobject, SpyderAction)): + keyseq = QKeySequence() + + try: + if isinstance(qobject, QAction): + if (sys.platform == 'darwin' + and qobject._shown_shortcut == 'missing'): + qobject._shown_shortcut = keyseq + else: + qobject.setShortcut(keyseq) + + if add_shortcut_to_tip: + add_shortcut_to_tooltip(qobject, context, name) + elif isinstance(qobject, QShortcut): + qobject.setKey(keyseq) + except RuntimeError: + # Object has been deleted + toberemoved.append(index) + + for index in sorted(toberemoved, reverse=True): + self._shortcut_data.pop(index) + + self.sig_shortcuts_updated.emit() + + def get_shortcut(self, context, name, plugin_name=None): + """ + Get keyboard shortcut (key sequence string). + + Parameters + ---------- + context: + Context must be either '_' for global or the name of a plugin. + name: str + Name of the shortcut. + plugin_id: spyder.api.plugins.SpyderpluginV2 or None + The plugin for which the shortcut is registered. Default is None. + + Returns + ------- + Shortcut + A shortcut object. + """ + return self._conf.get_shortcut(context, name, plugin_name=plugin_name) + + def set_shortcut(self, context, name, keystr, plugin_id=None): + """ + Set keyboard shortcut (key sequence string). + + Parameters + ---------- + context: + Context must be either '_' for global or the name of a plugin. + name: str + Name of the shortcut. + keystr: str + Shortcut keys in string form. + plugin_id: spyder.api.plugins.SpyderpluginV2 or None + The plugin for which the shortcut is registered. Default is None. + """ + self._conf.set_shortcut(context, name, keystr, plugin_name=plugin_id) diff --git a/spyder/plugins/shortcuts/widgets/table.py b/spyder/plugins/shortcuts/widgets/table.py index 5a375fb9913..1f95d42778d 100644 --- a/spyder/plugins/shortcuts/widgets/table.py +++ b/spyder/plugins/shortcuts/widgets/table.py @@ -1,940 +1,940 @@ -# -*- coding: utf-8 -*- -# -# Copyright © Spyder Project Contributors -# Licensed under the terms of the MIT License -# (see spyder/__init__.py for details) - -"""Shortcut management widgets.""" - -# Standard library importsimport re -import re - -# Third party imports -from qtpy.compat import from_qvariant, to_qvariant -from qtpy.QtCore import (QAbstractTableModel, QEvent, QModelIndex, - QSortFilterProxyModel, Qt, Slot) -from qtpy.QtGui import QIcon, QKeySequence -from qtpy.QtWidgets import (QAbstractItemView, QApplication, QDialog, - QGridLayout, QHBoxLayout, QKeySequenceEdit, - QLabel, QLineEdit, QMessageBox, QPushButton, - QSpacerItem, QTableView, QVBoxLayout) - -# Local imports -from spyder.api.translations import get_translation -from spyder.config.manager import CONF -from spyder.utils.icon_manager import ima -from spyder.utils.qthelpers import create_toolbutton -from spyder.utils.stringmatching import get_search_regex, get_search_scores -from spyder.widgets.helperwidgets import (VALID_FINDER_CHARS, - CustomSortFilterProxy, - FinderLineEdit, HelperToolButton, - HTMLDelegate) - -# Localization -_ = get_translation('spyder') - - -# Valid shortcut keys -SINGLE_KEYS = ["F{}".format(_i) for _i in range(1, 36)] + ["Del", "Esc"] -EDITOR_SINGLE_KEYS = SINGLE_KEYS + ["Home", "End", "Ins", "Enter", - "Return", "Backspace", "Tab", - "PageUp", "PageDown", "Clear", "Pause", - "Left", "Up", "Right", "Down"] - -# Key sequences blacklist for the shortcut editor dialog -BLACKLIST = {} - -# Error codes for the shortcut editor dialog -NO_WARNING = 0 -SEQUENCE_EMPTY = 1 -SEQUENCE_CONFLICT = 2 -INVALID_KEY = 3 -IN_BLACKLIST = 4 - - -class ShortcutTranslator(QKeySequenceEdit): - """ - A QKeySequenceEdit that is not meant to be shown and is used only - to convert QKeyEvent into QKeySequence. To our knowledge, this is - the only way to do this within the Qt framework, because the code that does - this in Qt is protected. Porting the code to Python would be nearly - impossible because it relies on low level and OS-dependent Qt libraries - that are not public for the most part. - """ - - def __init__(self): - super(ShortcutTranslator, self).__init__() - self.hide() - - def keyevent_to_keyseq(self, event): - """Return a QKeySequence representation of the provided QKeyEvent.""" - self.keyPressEvent(event) - event.accept() - return self.keySequence() - - def keyReleaseEvent(self, event): - """Qt Override""" - return False - - def timerEvent(self, event): - """Qt Override""" - return False - - def event(self, event): - """Qt Override""" - return False - - -class ShortcutLineEdit(QLineEdit): - """QLineEdit that filters its key press and release events.""" - - def __init__(self, parent): - super(ShortcutLineEdit, self).__init__(parent) - self.setReadOnly(True) - - tw = self.fontMetrics().width( - "Ctrl+Shift+Alt+Backspace, Ctrl+Shift+Alt+Backspace") - fw = self.style().pixelMetric(self.style().PM_DefaultFrameWidth) - self.setMinimumWidth(tw + (2 * fw) + 4) - # We need to add 4 to take into account the horizontalMargin of the - # line edit, whose value is hardcoded in qt. - - def keyPressEvent(self, e): - """Qt Override""" - self.parent().keyPressEvent(e) - - def keyReleaseEvent(self, e): - """Qt Override""" - self.parent().keyReleaseEvent(e) - - def setText(self, sequence): - """Qt method extension.""" - self.setToolTip(sequence) - super(ShortcutLineEdit, self).setText(sequence) - - -class ShortcutFinder(FinderLineEdit): - """Textbox for filtering listed shortcuts in the table.""" - - def keyPressEvent(self, event): - """Qt and FilterLineEdit Override.""" - key = event.key() - if key in [Qt.Key_Up]: - self._parent.previous_row() - elif key in [Qt.Key_Down]: - self._parent.next_row() - elif key in [Qt.Key_Enter, Qt.Key_Return]: - self._parent.show_editor() - else: - super(ShortcutFinder, self).keyPressEvent(event) - - -class ShortcutEditor(QDialog): - """A dialog for entering key sequences.""" - - def __init__(self, parent, context, name, sequence, shortcuts): - super(ShortcutEditor, self).__init__(parent) - self._parent = parent - self.setWindowFlags(self.windowFlags() & - ~Qt.WindowContextHelpButtonHint) - - self.context = context - self.name = name - self.shortcuts = shortcuts - self.current_sequence = sequence or _('') - self._qsequences = list() - - self.setup() - self.update_warning() - - @property - def new_sequence(self): - """Return a string representation of the new key sequence.""" - return ', '.join(self._qsequences) - - @property - def new_qsequence(self): - """Return the QKeySequence object of the new key sequence.""" - return QKeySequence(self.new_sequence) - - def setup(self): - """Setup the ShortcutEditor with the provided arguments.""" - # Widgets - icon_info = HelperToolButton() - icon_info.setIcon(ima.get_std_icon('MessageBoxInformation')) - layout_icon_info = QVBoxLayout() - layout_icon_info.setContentsMargins(0, 0, 0, 0) - layout_icon_info.setSpacing(0) - layout_icon_info.addWidget(icon_info) - layout_icon_info.addStretch(100) - - self.label_info = QLabel() - self.label_info.setText( - _("Press the new shortcut and select 'Ok' to confirm, " - "click 'Cancel' to revert to the previous state, " - "or use 'Clear' to unbind the command from a shortcut.")) - self.label_info.setAlignment(Qt.AlignTop | Qt.AlignLeft) - self.label_info.setWordWrap(True) - layout_info = QHBoxLayout() - layout_info.setContentsMargins(0, 0, 0, 0) - layout_info.addLayout(layout_icon_info) - layout_info.addWidget(self.label_info) - layout_info.setStretch(1, 100) - - self.label_current_sequence = QLabel(_("Current shortcut:")) - self.text_current_sequence = QLabel(self.current_sequence) - - self.label_new_sequence = QLabel(_("New shortcut:")) - self.text_new_sequence = ShortcutLineEdit(self) - self.text_new_sequence.setPlaceholderText(_("Press shortcut.")) - - self.helper_button = HelperToolButton() - self.helper_button.setIcon(QIcon()) - self.label_warning = QLabel() - self.label_warning.setWordWrap(True) - self.label_warning.setAlignment(Qt.AlignTop | Qt.AlignLeft) - - self.button_default = QPushButton(_('Default')) - self.button_ok = QPushButton(_('Ok')) - self.button_ok.setEnabled(False) - self.button_clear = QPushButton(_('Clear')) - self.button_cancel = QPushButton(_('Cancel')) - button_box = QHBoxLayout() - button_box.addWidget(self.button_default) - button_box.addStretch(100) - button_box.addWidget(self.button_ok) - button_box.addWidget(self.button_clear) - button_box.addWidget(self.button_cancel) - - # New Sequence button box - self.btn_clear_sequence = create_toolbutton( - self, icon=ima.icon('editclear'), - tip=_("Clear all entered key sequences"), - triggered=self.clear_new_sequence) - self.button_back_sequence = create_toolbutton( - self, icon=ima.icon('previous'), - tip=_("Remove last key sequence entered"), - triggered=self.back_new_sequence) - - newseq_btnbar = QHBoxLayout() - newseq_btnbar.setSpacing(0) - newseq_btnbar.setContentsMargins(0, 0, 0, 0) - newseq_btnbar.addWidget(self.button_back_sequence) - newseq_btnbar.addWidget(self.btn_clear_sequence) - - # Setup widgets - self.setWindowTitle(_('Shortcut: {0}').format(self.name)) - self.helper_button.setToolTip('') - style = """ - QToolButton { - margin:1px; - border: 0px solid grey; - padding:0px; - border-radius: 0px; - }""" - self.helper_button.setStyleSheet(style) - icon_info.setToolTip('') - icon_info.setStyleSheet(style) - - # Layout - layout_sequence = QGridLayout() - layout_sequence.setContentsMargins(0, 0, 0, 0) - layout_sequence.addLayout(layout_info, 0, 0, 1, 4) - layout_sequence.addItem(QSpacerItem(15, 15), 1, 0, 1, 4) - layout_sequence.addWidget(self.label_current_sequence, 2, 0) - layout_sequence.addWidget(self.text_current_sequence, 2, 2) - layout_sequence.addWidget(self.label_new_sequence, 3, 0) - layout_sequence.addWidget(self.helper_button, 3, 1) - layout_sequence.addWidget(self.text_new_sequence, 3, 2) - layout_sequence.addLayout(newseq_btnbar, 3, 3) - layout_sequence.addWidget(self.label_warning, 4, 2, 1, 2) - layout_sequence.setColumnStretch(2, 100) - layout_sequence.setRowStretch(4, 100) - - layout = QVBoxLayout(self) - layout.addLayout(layout_sequence) - layout.addSpacing(10) - layout.addLayout(button_box) - layout.setSizeConstraint(layout.SetFixedSize) - - # Signals - self.button_ok.clicked.connect(self.accept_override) - self.button_clear.clicked.connect(self.unbind_shortcut) - self.button_cancel.clicked.connect(self.reject) - self.button_default.clicked.connect(self.set_sequence_to_default) - - # Set all widget to no focus so that we can register key - # press event. - widgets = ( - self.label_warning, self.helper_button, self.text_new_sequence, - self.button_clear, self.button_default, self.button_cancel, - self.button_ok, self.btn_clear_sequence, self.button_back_sequence) - for w in widgets: - w.setFocusPolicy(Qt.NoFocus) - w.clearFocus() - - @Slot() - def reject(self): - """Slot for rejected signal.""" - # Added for spyder-ide/spyder#5426. Due to the focusPolicy of - # Qt.NoFocus for the buttons, if the cancel button was clicked without - # first setting focus to the button, it would cause a seg fault crash. - self.button_cancel.setFocus() - super(ShortcutEditor, self).reject() - - @Slot() - def accept(self): - """Slot for accepted signal.""" - # Added for spyder-ide/spyder#5426. Due to the focusPolicy of - # Qt.NoFocus for the buttons, if the cancel button was clicked without - # first setting focus to the button, it would cause a seg fault crash. - self.button_ok.setFocus() - super(ShortcutEditor, self).accept() - - def event(self, event): - """Qt method override.""" - # We reroute all ShortcutOverride events to our keyPressEvent and block - # any KeyPress and Shortcut event. This allows to register default - # Qt shortcuts for which no key press event are emitted. - # See spyder-ide/spyder/issues/10786. - if event.type() == QEvent.ShortcutOverride: - self.keyPressEvent(event) - return True - elif event.type() in [QEvent.KeyPress, QEvent.Shortcut]: - return True - else: - return super(ShortcutEditor, self).event(event) - - def keyPressEvent(self, event): - """Qt method override.""" - event_key = event.key() - if not event_key or event_key == Qt.Key_unknown: - return - if len(self._qsequences) == 4: - # QKeySequence accepts a maximum of 4 different sequences. - return - if event_key in [Qt.Key_Control, Qt.Key_Shift, - Qt.Key_Alt, Qt.Key_Meta]: - # The event corresponds to just and only a special key. - return - - translator = ShortcutTranslator() - event_keyseq = translator.keyevent_to_keyseq(event) - event_keystr = event_keyseq.toString(QKeySequence.PortableText) - self._qsequences.append(event_keystr) - self.update_warning() - - def check_conflicts(self): - """Check shortcuts for conflicts.""" - conflicts = [] - if len(self._qsequences) == 0: - return conflicts - - new_qsequence = self.new_qsequence - for shortcut in self.shortcuts: - shortcut_qsequence = QKeySequence.fromString(str(shortcut.key)) - if shortcut_qsequence.isEmpty(): - continue - if (shortcut.context, shortcut.name) == (self.context, self.name): - continue - if shortcut.context in [self.context, '_'] or self.context == '_': - if (shortcut_qsequence.matches(new_qsequence) or - new_qsequence.matches(shortcut_qsequence)): - conflicts.append(shortcut) - return conflicts - - def check_ascii(self): - """ - Check that all characters in the new sequence are ascii or else the - shortcut will not work. - """ - try: - self.new_sequence.encode('ascii') - except UnicodeEncodeError: - return False - else: - return True - - def check_singlekey(self): - """Check if the first sub-sequence of the new key sequence is valid.""" - if len(self._qsequences) == 0: - return True - else: - keystr = self._qsequences[0] - valid_single_keys = (EDITOR_SINGLE_KEYS if - self.context == 'editor' else SINGLE_KEYS) - if any((m in keystr for m in ('Ctrl', 'Alt', 'Shift', 'Meta'))): - return True - else: - # This means that the the first subsequence is composed of - # a single key with no modifier. - valid_single_keys = (EDITOR_SINGLE_KEYS if - self.context == 'editor' else SINGLE_KEYS) - if any((k == keystr for k in valid_single_keys)): - return True - else: - return False - - def update_warning(self): - """Update the warning label, buttons state and sequence text.""" - new_qsequence = self.new_qsequence - new_sequence = self.new_sequence - self.text_new_sequence.setText( - new_qsequence.toString(QKeySequence.NativeText)) - - conflicts = self.check_conflicts() - if len(self._qsequences) == 0: - warning = SEQUENCE_EMPTY - tip = '' - icon = QIcon() - elif conflicts: - warning = SEQUENCE_CONFLICT - template = '

    {0}

    {1}{2}' - tip_title = _('This key sequence conflicts with:') - tip_body = '' - for s in conflicts: - tip_body += ' ' * 2 - tip_body += ' - {0}: {1}
    '.format(s.context, s.name) - tip_body += '
    ' - if len(conflicts) == 1: - tip_override = _("Press 'Ok' to unbind it and assign it to") - else: - tip_override = _("Press 'Ok' to unbind them and assign it to") - tip_override += ' {}.'.format(self.name) - tip = template.format(tip_title, tip_body, tip_override) - icon = ima.get_std_icon('MessageBoxWarning') - elif new_sequence in BLACKLIST: - warning = IN_BLACKLIST - tip = _('This key sequence is forbidden.') - icon = ima.get_std_icon('MessageBoxWarning') - elif self.check_singlekey() is False or self.check_ascii() is False: - warning = INVALID_KEY - tip = _('This key sequence is invalid.') - icon = ima.get_std_icon('MessageBoxWarning') - else: - warning = NO_WARNING - tip = _('This key sequence is valid.') - icon = ima.get_std_icon('DialogApplyButton') - - self.warning = warning - self.conflicts = conflicts - - self.helper_button.setIcon(icon) - self.button_ok.setEnabled( - self.warning in [NO_WARNING, SEQUENCE_CONFLICT]) - self.label_warning.setText(tip) - - def set_sequence_from_str(self, sequence): - """ - This is a convenience method to set the new QKeySequence of the - shortcut editor from a string. - """ - self._qsequences = [QKeySequence(s) for s in sequence.split(', ')] - self.update_warning() - - def set_sequence_to_default(self): - """Set the new sequence to the default value defined in the config.""" - sequence = CONF.get_default( - 'shortcuts', "{}/{}".format(self.context, self.name)) - if sequence: - self._qsequences = sequence.split(', ') - self.update_warning() - else: - self.unbind_shortcut() - - def back_new_sequence(self): - """Remove the last subsequence from the sequence compound.""" - self._qsequences = self._qsequences[:-1] - self.update_warning() - - def clear_new_sequence(self): - """Clear the new sequence.""" - self._qsequences = [] - self.update_warning() - - def unbind_shortcut(self): - """Unbind the shortcut.""" - self._qsequences = [] - self.accept() - - def accept_override(self): - """Unbind all conflicted shortcuts, and accept the new one""" - conflicts = self.check_conflicts() - if conflicts: - for shortcut in conflicts: - shortcut.key = '' - self.accept() - - -class Shortcut(object): - """Shortcut convenience class for holding shortcut context, name, - original ordering index, key sequence for the shortcut and localized text. - """ - - def __init__(self, context, name, key=None): - self.index = 0 # Sorted index. Populated when loading shortcuts - self.context = context - self.name = name - self.key = key - - def __str__(self): - return "{0}/{1}: {2}".format(self.context, self.name, self.key) - - def load(self): - self.key = CONF.get_shortcut(self.context, self.name) - - def save(self): - CONF.set_shortcut(self.context, self.name, self.key) - - -CONTEXT, NAME, SEQUENCE, SEARCH_SCORE = [0, 1, 2, 3] - - -class ShortcutsModel(QAbstractTableModel): - def __init__(self, parent, text_color=None, text_color_highlight=None): - QAbstractTableModel.__init__(self) - self._parent = parent - - self.shortcuts = [] - self.scores = [] - self.rich_text = [] - self.normal_text = [] - self.context_rich_text = [] - self.letters = '' - self.label = QLabel() - self.widths = [] - - # Needed to compensate for the HTMLDelegate color selection unawarness - palette = parent.palette() - if text_color is None: - self.text_color = palette.text().color().name() - else: - self.text_color = text_color - - if text_color_highlight is None: - self.text_color_highlight = \ - palette.highlightedText().color().name() - else: - self.text_color_highlight = text_color_highlight - - def current_index(self): - """Get the currently selected index in the parent table view.""" - i = self._parent.proxy_model.mapToSource(self._parent.currentIndex()) - return i - - def sortByName(self): - """Qt Override.""" - self.shortcuts = sorted(self.shortcuts, - key=lambda x: x.context+'/'+x.name) - self.reset() - - def flags(self, index): - """Qt Override.""" - if not index.isValid(): - return Qt.ItemIsEnabled - return Qt.ItemFlags(int(QAbstractTableModel.flags(self, index))) - - def data(self, index, role=Qt.DisplayRole): - """Qt Override.""" - row = index.row() - if not index.isValid() or not (0 <= row < len(self.shortcuts)): - return to_qvariant() - - shortcut = self.shortcuts[row] - key = shortcut.key - column = index.column() - - if role == Qt.DisplayRole: - color = self.text_color - if self._parent == QApplication.focusWidget(): - if self.current_index().row() == row: - color = self.text_color_highlight - else: - color = self.text_color - if column == CONTEXT: - if len(self.context_rich_text) > 0: - text = self.context_rich_text[row] - else: - text = shortcut.context - text = '

    {1}

    '.format(color, text) - return to_qvariant(text) - elif column == NAME: - text = self.rich_text[row] - text = '

    {1}

    '.format(color, text) - return to_qvariant(text) - elif column == SEQUENCE: - text = QKeySequence(key).toString(QKeySequence.NativeText) - return to_qvariant(text) - elif column == SEARCH_SCORE: - # Treating search scores as a table column simplifies the - # sorting once a score for a specific string in the finder - # has been defined. This column however should always remain - # hidden. - return to_qvariant(self.scores[row]) - elif role == Qt.TextAlignmentRole: - return to_qvariant(int(Qt.AlignHCenter | Qt.AlignVCenter)) - return to_qvariant() - - def headerData(self, section, orientation, role=Qt.DisplayRole): - """Qt Override.""" - if role == Qt.TextAlignmentRole: - if orientation == Qt.Horizontal: - return to_qvariant(int(Qt.AlignHCenter | Qt.AlignVCenter)) - return to_qvariant(int(Qt.AlignRight | Qt.AlignVCenter)) - if role != Qt.DisplayRole: - return to_qvariant() - if orientation == Qt.Horizontal: - if section == CONTEXT: - return to_qvariant(_("Context")) - elif section == NAME: - return to_qvariant(_("Name")) - elif section == SEQUENCE: - return to_qvariant(_("Shortcut")) - elif section == SEARCH_SCORE: - return to_qvariant(_("Score")) - return to_qvariant() - - def rowCount(self, index=QModelIndex()): - """Qt Override.""" - return len(self.shortcuts) - - def columnCount(self, index=QModelIndex()): - """Qt Override.""" - return 4 - - def setData(self, index, value, role=Qt.EditRole): - """Qt Override.""" - if index.isValid() and 0 <= index.row() < len(self.shortcuts): - shortcut = self.shortcuts[index.row()] - column = index.column() - text = from_qvariant(value, str) - if column == SEQUENCE: - shortcut.key = text - self.dataChanged.emit(index, index) - return True - return False - - def update_search_letters(self, text): - """Update search letters with text input in search box.""" - self.letters = text - contexts = [shortcut.context for shortcut in self.shortcuts] - names = [shortcut.name for shortcut in self.shortcuts] - context_results = get_search_scores( - text, contexts, template='{0}') - results = get_search_scores(text, names, template='{0}') - __, self.context_rich_text, context_scores = ( - zip(*context_results)) - self.normal_text, self.rich_text, self.scores = zip(*results) - self.scores = [x + y for x, y in zip(self.scores, context_scores)] - self.reset() - - def update_active_row(self): - """Update active row to update color in selected text.""" - self.data(self.current_index()) - - def row(self, row_num): - """Get row based on model index. Needed for the custom proxy model.""" - return self.shortcuts[row_num] - - def reset(self): - """"Reset model to take into account new search letters.""" - self.beginResetModel() - self.endResetModel() - - -class ShortcutsTable(QTableView): - def __init__(self, - parent=None, text_color=None, text_color_highlight=None): - QTableView.__init__(self, parent) - self._parent = parent - self.finder = None - self.shortcut_data = None - self.source_model = ShortcutsModel( - self, - text_color=text_color, - text_color_highlight=text_color_highlight) - self.proxy_model = ShortcutsSortFilterProxy(self) - self.last_regex = '' - - self.proxy_model.setSourceModel(self.source_model) - self.proxy_model.setDynamicSortFilter(True) - self.proxy_model.setFilterByColumn(CONTEXT) - self.proxy_model.setFilterByColumn(NAME) - self.proxy_model.setFilterCaseSensitivity(Qt.CaseInsensitive) - self.setModel(self.proxy_model) - - self.hideColumn(SEARCH_SCORE) - self.setItemDelegateForColumn(NAME, HTMLDelegate(self, margin=9)) - self.setItemDelegateForColumn(CONTEXT, HTMLDelegate(self, margin=9)) - self.setSelectionBehavior(QAbstractItemView.SelectRows) - self.setSelectionMode(QAbstractItemView.SingleSelection) - self.setSortingEnabled(True) - self.setEditTriggers(QAbstractItemView.AllEditTriggers) - self.selectionModel().selectionChanged.connect(self.selection) - - self.verticalHeader().hide() - - def set_shortcut_data(self, shortcut_data): - """ - Shortcut data comes from the registration of actions on the main - window. This allows to only display the right actions on the - shortcut table. This also allows to display the localize text. - """ - self.shortcut_data = shortcut_data - - def focusOutEvent(self, e): - """Qt Override.""" - self.source_model.update_active_row() - super(ShortcutsTable, self).focusOutEvent(e) - - def focusInEvent(self, e): - """Qt Override.""" - super(ShortcutsTable, self).focusInEvent(e) - self.selectRow(self.currentIndex().row()) - - def selection(self, index): - """Update selected row.""" - self.update() - self.isActiveWindow() - - def adjust_cells(self): - """Adjust column size based on contents.""" - self.resizeColumnsToContents() - fm = self.horizontalHeader().fontMetrics() - names = [fm.width(s.name + ' '*9) for s in self.source_model.shortcuts] - if len(names) == 0: - # This condition only applies during testing - names = [0] - self.setColumnWidth(NAME, max(names)) - self.horizontalHeader().setStretchLastSection(True) - - def load_shortcuts(self): - """Load shortcuts and assign to table model.""" - # item[1] -> context, item[2] -> name - # Data might be capitalized so we user lower() - # See: spyder-ide/spyder/#12415 - shortcut_data = set([(item[1].lower(), item[2].lower()) for item - in self.shortcut_data]) - shortcut_data = list(sorted(set(shortcut_data))) - shortcuts = [] - - for context, name, keystr in CONF.iter_shortcuts(): - if (context, name) in shortcut_data: - context = context.lower() - name = name.lower() - # Only add to table actions that are registered from the main - # window - shortcut = Shortcut(context, name, keystr) - shortcuts.append(shortcut) - - shortcuts = sorted(shortcuts, key=lambda item: item.context+item.name) - - # Store the original order of shortcuts - for i, shortcut in enumerate(shortcuts): - shortcut.index = i - - self.source_model.shortcuts = shortcuts - self.source_model.scores = [0]*len(shortcuts) - self.source_model.rich_text = [s.name for s in shortcuts] - self.source_model.reset() - self.adjust_cells() - self.sortByColumn(CONTEXT, Qt.AscendingOrder) - - def check_shortcuts(self): - """Check shortcuts for conflicts.""" - conflicts = [] - for index, sh1 in enumerate(self.source_model.shortcuts): - if index == len(self.source_model.shortcuts)-1: - break - if str(sh1.key) == '': - continue - for sh2 in self.source_model.shortcuts[index+1:]: - if sh2 is sh1: - continue - if str(sh2.key) == str(sh1.key) \ - and (sh1.context == sh2.context or sh1.context == '_' or - sh2.context == '_'): - conflicts.append((sh1, sh2)) - if conflicts: - self.parent().show_this_page.emit() - cstr = "\n".join(['%s <---> %s' % (sh1, sh2) - for sh1, sh2 in conflicts]) - QMessageBox.warning(self, _("Conflicts"), - _("The following conflicts have been " - "detected:")+"\n"+cstr, QMessageBox.Ok) - - def save_shortcuts(self): - """Save shortcuts from table model.""" - self.check_shortcuts() - for shortcut in self.source_model.shortcuts: - shortcut.save() - - def show_editor(self): - """Create, setup and display the shortcut editor dialog.""" - index = self.proxy_model.mapToSource(self.currentIndex()) - row, column = index.row(), index.column() - shortcuts = self.source_model.shortcuts - context = shortcuts[row].context - name = shortcuts[row].name - - sequence_index = self.source_model.index(row, SEQUENCE) - sequence = sequence_index.data() - - dialog = ShortcutEditor(self, context, name, sequence, shortcuts) - - if dialog.exec_(): - new_sequence = dialog.new_sequence - self.source_model.setData(sequence_index, new_sequence) - - def set_regex(self, regex=None, reset=False): - """Update the regex text for the shortcut finder.""" - if reset: - text = '' - else: - text = self.finder.text().replace(' ', '').lower() - - self.proxy_model.set_filter(text) - self.source_model.update_search_letters(text) - self.sortByColumn(SEARCH_SCORE, Qt.AscendingOrder) - - if self.last_regex != regex: - self.selectRow(0) - self.last_regex = regex - - def next_row(self): - """Move to next row from currently selected row.""" - row = self.currentIndex().row() - rows = self.proxy_model.rowCount() - if row + 1 == rows: - row = -1 - self.selectRow(row + 1) - - def previous_row(self): - """Move to previous row from currently selected row.""" - row = self.currentIndex().row() - rows = self.proxy_model.rowCount() - if row == 0: - row = rows - self.selectRow(row - 1) - - def keyPressEvent(self, event): - """Qt Override.""" - key = event.key() - if key in [Qt.Key_Enter, Qt.Key_Return]: - self.show_editor() - elif key in [Qt.Key_Tab]: - self.finder.setFocus() - elif key in [Qt.Key_Backtab]: - self.parent().reset_btn.setFocus() - elif key in [Qt.Key_Up, Qt.Key_Down, Qt.Key_Left, Qt.Key_Right]: - super(ShortcutsTable, self).keyPressEvent(event) - elif key not in [Qt.Key_Escape, Qt.Key_Space]: - text = event.text() - if text: - if re.search(VALID_FINDER_CHARS, text) is not None: - self.finder.setFocus() - self.finder.set_text(text) - elif key in [Qt.Key_Escape]: - self.finder.keyPressEvent(event) - - def mouseDoubleClickEvent(self, event): - """Qt Override.""" - self.show_editor() - self.update() - - -class ShortcutsSortFilterProxy(QSortFilterProxyModel): - """Custom proxy for supporting shortcuts multifiltering.""" - - def __init__(self, parent=None): - """Initialize the multiple sort filter proxy.""" - super(ShortcutsSortFilterProxy, self).__init__(parent) - self._parent = parent - self.pattern = re.compile(r'') - self.filters = {} - - def setFilterByColumn(self, column): - """Set regular expression in the given column.""" - self.filters[column] = self.pattern - self.invalidateFilter() - - def set_filter(self, text): - """Set regular expression for filter.""" - for key, __ in self.filters.items(): - self.pattern = get_search_regex(text) - if self.pattern and text: - self._parent.setSortingEnabled(False) - else: - self._parent.setSortingEnabled(True) - self.filters[key] = self.pattern - self.invalidateFilter() - - def clearFilter(self, column): - """Clear the filter of the given column.""" - self.filters.pop(column) - self.invalidateFilter() - - def clearFilters(self): - """Clear all the filters.""" - self.filters = {} - self.invalidateFilter() - - def filterAcceptsRow(self, row_num, parent): - """Qt override. - - Reimplemented to allow filtering in multiple columns. - """ - results = [] - for key, regex in self.filters.items(): - model = self.sourceModel() - idx = model.index(row_num, key, parent) - if idx.isValid(): - name = model.row(row_num).name - r_name = re.search(regex, name) - if r_name is None: - r_name = '' - context = model.row(row_num).context - r_context = re.search(regex, context) - if r_context is None: - r_context = '' - results.append(r_name) - results.append(r_context) - return any(results) - - -def load_shortcuts_data(): - """ - Load shortcuts from CONF for testing. - """ - shortcut_data = [] - for context, name, __ in CONF.iter_shortcuts(): - context = context.lower() - name = name.lower() - shortcut_data.append((None, context, name, None, None)) - return shortcut_data - - -def load_shortcuts(shortcut_table): - """ - Load shortcuts into `shortcut_table`. - """ - shortcut_data = load_shortcuts_data() - shortcut_table.set_shortcut_data(shortcut_data) - shortcut_table.load_shortcuts() - return shortcut_table - - -def test(): - from spyder.utils.qthelpers import qapplication - - app = qapplication() - table = ShortcutsTable() - table = load_shortcuts(table) - table.show() - app.exec_() - - table.check_shortcuts() - - -if __name__ == '__main__': - test() +# -*- coding: utf-8 -*- +# +# Copyright © Spyder Project Contributors +# Licensed under the terms of the MIT License +# (see spyder/__init__.py for details) + +"""Shortcut management widgets.""" + +# Standard library importsimport re +import re + +# Third party imports +from qtpy.compat import from_qvariant, to_qvariant +from qtpy.QtCore import (QAbstractTableModel, QEvent, QModelIndex, + QSortFilterProxyModel, Qt, Slot) +from qtpy.QtGui import QIcon, QKeySequence +from qtpy.QtWidgets import (QAbstractItemView, QApplication, QDialog, + QGridLayout, QHBoxLayout, QKeySequenceEdit, + QLabel, QLineEdit, QMessageBox, QPushButton, + QSpacerItem, QTableView, QVBoxLayout) + +# Local imports +from spyder.api.translations import get_translation +from spyder.config.manager import CONF +from spyder.utils.icon_manager import ima +from spyder.utils.qthelpers import create_toolbutton +from spyder.utils.stringmatching import get_search_regex, get_search_scores +from spyder.widgets.helperwidgets import (VALID_FINDER_CHARS, + CustomSortFilterProxy, + FinderLineEdit, HelperToolButton, + HTMLDelegate) + +# Localization +_ = get_translation('spyder') + + +# Valid shortcut keys +SINGLE_KEYS = ["F{}".format(_i) for _i in range(1, 36)] + ["Del", "Esc"] +EDITOR_SINGLE_KEYS = SINGLE_KEYS + ["Home", "End", "Ins", "Enter", + "Return", "Backspace", "Tab", + "PageUp", "PageDown", "Clear", "Pause", + "Left", "Up", "Right", "Down"] + +# Key sequences blacklist for the shortcut editor dialog +BLACKLIST = {} + +# Error codes for the shortcut editor dialog +NO_WARNING = 0 +SEQUENCE_EMPTY = 1 +SEQUENCE_CONFLICT = 2 +INVALID_KEY = 3 +IN_BLACKLIST = 4 + + +class ShortcutTranslator(QKeySequenceEdit): + """ + A QKeySequenceEdit that is not meant to be shown and is used only + to convert QKeyEvent into QKeySequence. To our knowledge, this is + the only way to do this within the Qt framework, because the code that does + this in Qt is protected. Porting the code to Python would be nearly + impossible because it relies on low level and OS-dependent Qt libraries + that are not public for the most part. + """ + + def __init__(self): + super(ShortcutTranslator, self).__init__() + self.hide() + + def keyevent_to_keyseq(self, event): + """Return a QKeySequence representation of the provided QKeyEvent.""" + self.keyPressEvent(event) + event.accept() + return self.keySequence() + + def keyReleaseEvent(self, event): + """Qt Override""" + return False + + def timerEvent(self, event): + """Qt Override""" + return False + + def event(self, event): + """Qt Override""" + return False + + +class ShortcutLineEdit(QLineEdit): + """QLineEdit that filters its key press and release events.""" + + def __init__(self, parent): + super(ShortcutLineEdit, self).__init__(parent) + self.setReadOnly(True) + + tw = self.fontMetrics().width( + "Ctrl+Shift+Alt+Backspace, Ctrl+Shift+Alt+Backspace") + fw = self.style().pixelMetric(self.style().PM_DefaultFrameWidth) + self.setMinimumWidth(tw + (2 * fw) + 4) + # We need to add 4 to take into account the horizontalMargin of the + # line edit, whose value is hardcoded in qt. + + def keyPressEvent(self, e): + """Qt Override""" + self.parent().keyPressEvent(e) + + def keyReleaseEvent(self, e): + """Qt Override""" + self.parent().keyReleaseEvent(e) + + def setText(self, sequence): + """Qt method extension.""" + self.setToolTip(sequence) + super(ShortcutLineEdit, self).setText(sequence) + + +class ShortcutFinder(FinderLineEdit): + """Textbox for filtering listed shortcuts in the table.""" + + def keyPressEvent(self, event): + """Qt and FilterLineEdit Override.""" + key = event.key() + if key in [Qt.Key_Up]: + self._parent.previous_row() + elif key in [Qt.Key_Down]: + self._parent.next_row() + elif key in [Qt.Key_Enter, Qt.Key_Return]: + self._parent.show_editor() + else: + super(ShortcutFinder, self).keyPressEvent(event) + + +class ShortcutEditor(QDialog): + """A dialog for entering key sequences.""" + + def __init__(self, parent, context, name, sequence, shortcuts): + super(ShortcutEditor, self).__init__(parent) + self._parent = parent + self.setWindowFlags(self.windowFlags() & + ~Qt.WindowContextHelpButtonHint) + + self.context = context + self.name = name + self.shortcuts = shortcuts + self.current_sequence = sequence or _('') + self._qsequences = list() + + self.setup() + self.update_warning() + + @property + def new_sequence(self): + """Return a string representation of the new key sequence.""" + return ', '.join(self._qsequences) + + @property + def new_qsequence(self): + """Return the QKeySequence object of the new key sequence.""" + return QKeySequence(self.new_sequence) + + def setup(self): + """Setup the ShortcutEditor with the provided arguments.""" + # Widgets + icon_info = HelperToolButton() + icon_info.setIcon(ima.get_std_icon('MessageBoxInformation')) + layout_icon_info = QVBoxLayout() + layout_icon_info.setContentsMargins(0, 0, 0, 0) + layout_icon_info.setSpacing(0) + layout_icon_info.addWidget(icon_info) + layout_icon_info.addStretch(100) + + self.label_info = QLabel() + self.label_info.setText( + _("Press the new shortcut and select 'Ok' to confirm, " + "click 'Cancel' to revert to the previous state, " + "or use 'Clear' to unbind the command from a shortcut.")) + self.label_info.setAlignment(Qt.AlignTop | Qt.AlignLeft) + self.label_info.setWordWrap(True) + layout_info = QHBoxLayout() + layout_info.setContentsMargins(0, 0, 0, 0) + layout_info.addLayout(layout_icon_info) + layout_info.addWidget(self.label_info) + layout_info.setStretch(1, 100) + + self.label_current_sequence = QLabel(_("Current shortcut:")) + self.text_current_sequence = QLabel(self.current_sequence) + + self.label_new_sequence = QLabel(_("New shortcut:")) + self.text_new_sequence = ShortcutLineEdit(self) + self.text_new_sequence.setPlaceholderText(_("Press shortcut.")) + + self.helper_button = HelperToolButton() + self.helper_button.setIcon(QIcon()) + self.label_warning = QLabel() + self.label_warning.setWordWrap(True) + self.label_warning.setAlignment(Qt.AlignTop | Qt.AlignLeft) + + self.button_default = QPushButton(_('Default')) + self.button_ok = QPushButton(_('Ok')) + self.button_ok.setEnabled(False) + self.button_clear = QPushButton(_('Clear')) + self.button_cancel = QPushButton(_('Cancel')) + button_box = QHBoxLayout() + button_box.addWidget(self.button_default) + button_box.addStretch(100) + button_box.addWidget(self.button_ok) + button_box.addWidget(self.button_clear) + button_box.addWidget(self.button_cancel) + + # New Sequence button box + self.btn_clear_sequence = create_toolbutton( + self, icon=ima.icon('editclear'), + tip=_("Clear all entered key sequences"), + triggered=self.clear_new_sequence) + self.button_back_sequence = create_toolbutton( + self, icon=ima.icon('previous'), + tip=_("Remove last key sequence entered"), + triggered=self.back_new_sequence) + + newseq_btnbar = QHBoxLayout() + newseq_btnbar.setSpacing(0) + newseq_btnbar.setContentsMargins(0, 0, 0, 0) + newseq_btnbar.addWidget(self.button_back_sequence) + newseq_btnbar.addWidget(self.btn_clear_sequence) + + # Setup widgets + self.setWindowTitle(_('Shortcut: {0}').format(self.name)) + self.helper_button.setToolTip('') + style = """ + QToolButton { + margin:1px; + border: 0px solid grey; + padding:0px; + border-radius: 0px; + }""" + self.helper_button.setStyleSheet(style) + icon_info.setToolTip('') + icon_info.setStyleSheet(style) + + # Layout + layout_sequence = QGridLayout() + layout_sequence.setContentsMargins(0, 0, 0, 0) + layout_sequence.addLayout(layout_info, 0, 0, 1, 4) + layout_sequence.addItem(QSpacerItem(15, 15), 1, 0, 1, 4) + layout_sequence.addWidget(self.label_current_sequence, 2, 0) + layout_sequence.addWidget(self.text_current_sequence, 2, 2) + layout_sequence.addWidget(self.label_new_sequence, 3, 0) + layout_sequence.addWidget(self.helper_button, 3, 1) + layout_sequence.addWidget(self.text_new_sequence, 3, 2) + layout_sequence.addLayout(newseq_btnbar, 3, 3) + layout_sequence.addWidget(self.label_warning, 4, 2, 1, 2) + layout_sequence.setColumnStretch(2, 100) + layout_sequence.setRowStretch(4, 100) + + layout = QVBoxLayout(self) + layout.addLayout(layout_sequence) + layout.addSpacing(10) + layout.addLayout(button_box) + layout.setSizeConstraint(layout.SetFixedSize) + + # Signals + self.button_ok.clicked.connect(self.accept_override) + self.button_clear.clicked.connect(self.unbind_shortcut) + self.button_cancel.clicked.connect(self.reject) + self.button_default.clicked.connect(self.set_sequence_to_default) + + # Set all widget to no focus so that we can register key + # press event. + widgets = ( + self.label_warning, self.helper_button, self.text_new_sequence, + self.button_clear, self.button_default, self.button_cancel, + self.button_ok, self.btn_clear_sequence, self.button_back_sequence) + for w in widgets: + w.setFocusPolicy(Qt.NoFocus) + w.clearFocus() + + @Slot() + def reject(self): + """Slot for rejected signal.""" + # Added for spyder-ide/spyder#5426. Due to the focusPolicy of + # Qt.NoFocus for the buttons, if the cancel button was clicked without + # first setting focus to the button, it would cause a seg fault crash. + self.button_cancel.setFocus() + super(ShortcutEditor, self).reject() + + @Slot() + def accept(self): + """Slot for accepted signal.""" + # Added for spyder-ide/spyder#5426. Due to the focusPolicy of + # Qt.NoFocus for the buttons, if the cancel button was clicked without + # first setting focus to the button, it would cause a seg fault crash. + self.button_ok.setFocus() + super(ShortcutEditor, self).accept() + + def event(self, event): + """Qt method override.""" + # We reroute all ShortcutOverride events to our keyPressEvent and block + # any KeyPress and Shortcut event. This allows to register default + # Qt shortcuts for which no key press event are emitted. + # See spyder-ide/spyder/issues/10786. + if event.type() == QEvent.ShortcutOverride: + self.keyPressEvent(event) + return True + elif event.type() in [QEvent.KeyPress, QEvent.Shortcut]: + return True + else: + return super(ShortcutEditor, self).event(event) + + def keyPressEvent(self, event): + """Qt method override.""" + event_key = event.key() + if not event_key or event_key == Qt.Key_unknown: + return + if len(self._qsequences) == 4: + # QKeySequence accepts a maximum of 4 different sequences. + return + if event_key in [Qt.Key_Control, Qt.Key_Shift, + Qt.Key_Alt, Qt.Key_Meta]: + # The event corresponds to just and only a special key. + return + + translator = ShortcutTranslator() + event_keyseq = translator.keyevent_to_keyseq(event) + event_keystr = event_keyseq.toString(QKeySequence.PortableText) + self._qsequences.append(event_keystr) + self.update_warning() + + def check_conflicts(self): + """Check shortcuts for conflicts.""" + conflicts = [] + if len(self._qsequences) == 0: + return conflicts + + new_qsequence = self.new_qsequence + for shortcut in self.shortcuts: + shortcut_qsequence = QKeySequence.fromString(str(shortcut.key)) + if shortcut_qsequence.isEmpty(): + continue + if (shortcut.context, shortcut.name) == (self.context, self.name): + continue + if shortcut.context in [self.context, '_'] or self.context == '_': + if (shortcut_qsequence.matches(new_qsequence) or + new_qsequence.matches(shortcut_qsequence)): + conflicts.append(shortcut) + return conflicts + + def check_ascii(self): + """ + Check that all characters in the new sequence are ascii or else the + shortcut will not work. + """ + try: + self.new_sequence.encode('ascii') + except UnicodeEncodeError: + return False + else: + return True + + def check_singlekey(self): + """Check if the first sub-sequence of the new key sequence is valid.""" + if len(self._qsequences) == 0: + return True + else: + keystr = self._qsequences[0] + valid_single_keys = (EDITOR_SINGLE_KEYS if + self.context == 'editor' else SINGLE_KEYS) + if any((m in keystr for m in ('Ctrl', 'Alt', 'Shift', 'Meta'))): + return True + else: + # This means that the the first subsequence is composed of + # a single key with no modifier. + valid_single_keys = (EDITOR_SINGLE_KEYS if + self.context == 'editor' else SINGLE_KEYS) + if any((k == keystr for k in valid_single_keys)): + return True + else: + return False + + def update_warning(self): + """Update the warning label, buttons state and sequence text.""" + new_qsequence = self.new_qsequence + new_sequence = self.new_sequence + self.text_new_sequence.setText( + new_qsequence.toString(QKeySequence.NativeText)) + + conflicts = self.check_conflicts() + if len(self._qsequences) == 0: + warning = SEQUENCE_EMPTY + tip = '' + icon = QIcon() + elif conflicts: + warning = SEQUENCE_CONFLICT + template = '

    {0}

    {1}{2}' + tip_title = _('This key sequence conflicts with:') + tip_body = '' + for s in conflicts: + tip_body += ' ' * 2 + tip_body += ' - {0}: {1}
    '.format(s.context, s.name) + tip_body += '
    ' + if len(conflicts) == 1: + tip_override = _("Press 'Ok' to unbind it and assign it to") + else: + tip_override = _("Press 'Ok' to unbind them and assign it to") + tip_override += ' {}.'.format(self.name) + tip = template.format(tip_title, tip_body, tip_override) + icon = ima.get_std_icon('MessageBoxWarning') + elif new_sequence in BLACKLIST: + warning = IN_BLACKLIST + tip = _('This key sequence is forbidden.') + icon = ima.get_std_icon('MessageBoxWarning') + elif self.check_singlekey() is False or self.check_ascii() is False: + warning = INVALID_KEY + tip = _('This key sequence is invalid.') + icon = ima.get_std_icon('MessageBoxWarning') + else: + warning = NO_WARNING + tip = _('This key sequence is valid.') + icon = ima.get_std_icon('DialogApplyButton') + + self.warning = warning + self.conflicts = conflicts + + self.helper_button.setIcon(icon) + self.button_ok.setEnabled( + self.warning in [NO_WARNING, SEQUENCE_CONFLICT]) + self.label_warning.setText(tip) + + def set_sequence_from_str(self, sequence): + """ + This is a convenience method to set the new QKeySequence of the + shortcut editor from a string. + """ + self._qsequences = [QKeySequence(s) for s in sequence.split(', ')] + self.update_warning() + + def set_sequence_to_default(self): + """Set the new sequence to the default value defined in the config.""" + sequence = CONF.get_default( + 'shortcuts', "{}/{}".format(self.context, self.name)) + if sequence: + self._qsequences = sequence.split(', ') + self.update_warning() + else: + self.unbind_shortcut() + + def back_new_sequence(self): + """Remove the last subsequence from the sequence compound.""" + self._qsequences = self._qsequences[:-1] + self.update_warning() + + def clear_new_sequence(self): + """Clear the new sequence.""" + self._qsequences = [] + self.update_warning() + + def unbind_shortcut(self): + """Unbind the shortcut.""" + self._qsequences = [] + self.accept() + + def accept_override(self): + """Unbind all conflicted shortcuts, and accept the new one""" + conflicts = self.check_conflicts() + if conflicts: + for shortcut in conflicts: + shortcut.key = '' + self.accept() + + +class Shortcut(object): + """Shortcut convenience class for holding shortcut context, name, + original ordering index, key sequence for the shortcut and localized text. + """ + + def __init__(self, context, name, key=None): + self.index = 0 # Sorted index. Populated when loading shortcuts + self.context = context + self.name = name + self.key = key + + def __str__(self): + return "{0}/{1}: {2}".format(self.context, self.name, self.key) + + def load(self): + self.key = CONF.get_shortcut(self.context, self.name) + + def save(self): + CONF.set_shortcut(self.context, self.name, self.key) + + +CONTEXT, NAME, SEQUENCE, SEARCH_SCORE = [0, 1, 2, 3] + + +class ShortcutsModel(QAbstractTableModel): + def __init__(self, parent, text_color=None, text_color_highlight=None): + QAbstractTableModel.__init__(self) + self._parent = parent + + self.shortcuts = [] + self.scores = [] + self.rich_text = [] + self.normal_text = [] + self.context_rich_text = [] + self.letters = '' + self.label = QLabel() + self.widths = [] + + # Needed to compensate for the HTMLDelegate color selection unawarness + palette = parent.palette() + if text_color is None: + self.text_color = palette.text().color().name() + else: + self.text_color = text_color + + if text_color_highlight is None: + self.text_color_highlight = \ + palette.highlightedText().color().name() + else: + self.text_color_highlight = text_color_highlight + + def current_index(self): + """Get the currently selected index in the parent table view.""" + i = self._parent.proxy_model.mapToSource(self._parent.currentIndex()) + return i + + def sortByName(self): + """Qt Override.""" + self.shortcuts = sorted(self.shortcuts, + key=lambda x: x.context+'/'+x.name) + self.reset() + + def flags(self, index): + """Qt Override.""" + if not index.isValid(): + return Qt.ItemIsEnabled + return Qt.ItemFlags(int(QAbstractTableModel.flags(self, index))) + + def data(self, index, role=Qt.DisplayRole): + """Qt Override.""" + row = index.row() + if not index.isValid() or not (0 <= row < len(self.shortcuts)): + return to_qvariant() + + shortcut = self.shortcuts[row] + key = shortcut.key + column = index.column() + + if role == Qt.DisplayRole: + color = self.text_color + if self._parent == QApplication.focusWidget(): + if self.current_index().row() == row: + color = self.text_color_highlight + else: + color = self.text_color + if column == CONTEXT: + if len(self.context_rich_text) > 0: + text = self.context_rich_text[row] + else: + text = shortcut.context + text = '

    {1}

    '.format(color, text) + return to_qvariant(text) + elif column == NAME: + text = self.rich_text[row] + text = '

    {1}

    '.format(color, text) + return to_qvariant(text) + elif column == SEQUENCE: + text = QKeySequence(key).toString(QKeySequence.NativeText) + return to_qvariant(text) + elif column == SEARCH_SCORE: + # Treating search scores as a table column simplifies the + # sorting once a score for a specific string in the finder + # has been defined. This column however should always remain + # hidden. + return to_qvariant(self.scores[row]) + elif role == Qt.TextAlignmentRole: + return to_qvariant(int(Qt.AlignHCenter | Qt.AlignVCenter)) + return to_qvariant() + + def headerData(self, section, orientation, role=Qt.DisplayRole): + """Qt Override.""" + if role == Qt.TextAlignmentRole: + if orientation == Qt.Horizontal: + return to_qvariant(int(Qt.AlignHCenter | Qt.AlignVCenter)) + return to_qvariant(int(Qt.AlignRight | Qt.AlignVCenter)) + if role != Qt.DisplayRole: + return to_qvariant() + if orientation == Qt.Horizontal: + if section == CONTEXT: + return to_qvariant(_("Context")) + elif section == NAME: + return to_qvariant(_("Name")) + elif section == SEQUENCE: + return to_qvariant(_("Shortcut")) + elif section == SEARCH_SCORE: + return to_qvariant(_("Score")) + return to_qvariant() + + def rowCount(self, index=QModelIndex()): + """Qt Override.""" + return len(self.shortcuts) + + def columnCount(self, index=QModelIndex()): + """Qt Override.""" + return 4 + + def setData(self, index, value, role=Qt.EditRole): + """Qt Override.""" + if index.isValid() and 0 <= index.row() < len(self.shortcuts): + shortcut = self.shortcuts[index.row()] + column = index.column() + text = from_qvariant(value, str) + if column == SEQUENCE: + shortcut.key = text + self.dataChanged.emit(index, index) + return True + return False + + def update_search_letters(self, text): + """Update search letters with text input in search box.""" + self.letters = text + contexts = [shortcut.context for shortcut in self.shortcuts] + names = [shortcut.name for shortcut in self.shortcuts] + context_results = get_search_scores( + text, contexts, template='{0}') + results = get_search_scores(text, names, template='{0}') + __, self.context_rich_text, context_scores = ( + zip(*context_results)) + self.normal_text, self.rich_text, self.scores = zip(*results) + self.scores = [x + y for x, y in zip(self.scores, context_scores)] + self.reset() + + def update_active_row(self): + """Update active row to update color in selected text.""" + self.data(self.current_index()) + + def row(self, row_num): + """Get row based on model index. Needed for the custom proxy model.""" + return self.shortcuts[row_num] + + def reset(self): + """"Reset model to take into account new search letters.""" + self.beginResetModel() + self.endResetModel() + + +class ShortcutsTable(QTableView): + def __init__(self, + parent=None, text_color=None, text_color_highlight=None): + QTableView.__init__(self, parent) + self._parent = parent + self.finder = None + self.shortcut_data = None + self.source_model = ShortcutsModel( + self, + text_color=text_color, + text_color_highlight=text_color_highlight) + self.proxy_model = ShortcutsSortFilterProxy(self) + self.last_regex = '' + + self.proxy_model.setSourceModel(self.source_model) + self.proxy_model.setDynamicSortFilter(True) + self.proxy_model.setFilterByColumn(CONTEXT) + self.proxy_model.setFilterByColumn(NAME) + self.proxy_model.setFilterCaseSensitivity(Qt.CaseInsensitive) + self.setModel(self.proxy_model) + + self.hideColumn(SEARCH_SCORE) + self.setItemDelegateForColumn(NAME, HTMLDelegate(self, margin=9)) + self.setItemDelegateForColumn(CONTEXT, HTMLDelegate(self, margin=9)) + self.setSelectionBehavior(QAbstractItemView.SelectRows) + self.setSelectionMode(QAbstractItemView.SingleSelection) + self.setSortingEnabled(True) + self.setEditTriggers(QAbstractItemView.AllEditTriggers) + self.selectionModel().selectionChanged.connect(self.selection) + + self.verticalHeader().hide() + + def set_shortcut_data(self, shortcut_data): + """ + Shortcut data comes from the registration of actions on the main + window. This allows to only display the right actions on the + shortcut table. This also allows to display the localize text. + """ + self.shortcut_data = shortcut_data + + def focusOutEvent(self, e): + """Qt Override.""" + self.source_model.update_active_row() + super(ShortcutsTable, self).focusOutEvent(e) + + def focusInEvent(self, e): + """Qt Override.""" + super(ShortcutsTable, self).focusInEvent(e) + self.selectRow(self.currentIndex().row()) + + def selection(self, index): + """Update selected row.""" + self.update() + self.isActiveWindow() + + def adjust_cells(self): + """Adjust column size based on contents.""" + self.resizeColumnsToContents() + fm = self.horizontalHeader().fontMetrics() + names = [fm.width(s.name + ' '*9) for s in self.source_model.shortcuts] + if len(names) == 0: + # This condition only applies during testing + names = [0] + self.setColumnWidth(NAME, max(names)) + self.horizontalHeader().setStretchLastSection(True) + + def load_shortcuts(self): + """Load shortcuts and assign to table model.""" + # item[1] -> context, item[2] -> name + # Data might be capitalized so we user lower() + # See: spyder-ide/spyder/#12415 + shortcut_data = set([(item[1].lower(), item[2].lower()) for item + in self.shortcut_data]) + shortcut_data = list(sorted(set(shortcut_data))) + shortcuts = [] + + for context, name, keystr in CONF.iter_shortcuts(): + if (context, name) in shortcut_data: + context = context.lower() + name = name.lower() + # Only add to table actions that are registered from the main + # window + shortcut = Shortcut(context, name, keystr) + shortcuts.append(shortcut) + + shortcuts = sorted(shortcuts, key=lambda item: item.context+item.name) + + # Store the original order of shortcuts + for i, shortcut in enumerate(shortcuts): + shortcut.index = i + + self.source_model.shortcuts = shortcuts + self.source_model.scores = [0]*len(shortcuts) + self.source_model.rich_text = [s.name for s in shortcuts] + self.source_model.reset() + self.adjust_cells() + self.sortByColumn(CONTEXT, Qt.AscendingOrder) + + def check_shortcuts(self): + """Check shortcuts for conflicts.""" + conflicts = [] + for index, sh1 in enumerate(self.source_model.shortcuts): + if index == len(self.source_model.shortcuts)-1: + break + if str(sh1.key) == '': + continue + for sh2 in self.source_model.shortcuts[index+1:]: + if sh2 is sh1: + continue + if str(sh2.key) == str(sh1.key) \ + and (sh1.context == sh2.context or sh1.context == '_' or + sh2.context == '_'): + conflicts.append((sh1, sh2)) + if conflicts: + self.parent().show_this_page.emit() + cstr = "\n".join(['%s <---> %s' % (sh1, sh2) + for sh1, sh2 in conflicts]) + QMessageBox.warning(self, _("Conflicts"), + _("The following conflicts have been " + "detected:")+"\n"+cstr, QMessageBox.Ok) + + def save_shortcuts(self): + """Save shortcuts from table model.""" + self.check_shortcuts() + for shortcut in self.source_model.shortcuts: + shortcut.save() + + def show_editor(self): + """Create, setup and display the shortcut editor dialog.""" + index = self.proxy_model.mapToSource(self.currentIndex()) + row, column = index.row(), index.column() + shortcuts = self.source_model.shortcuts + context = shortcuts[row].context + name = shortcuts[row].name + + sequence_index = self.source_model.index(row, SEQUENCE) + sequence = sequence_index.data() + + dialog = ShortcutEditor(self, context, name, sequence, shortcuts) + + if dialog.exec_(): + new_sequence = dialog.new_sequence + self.source_model.setData(sequence_index, new_sequence) + + def set_regex(self, regex=None, reset=False): + """Update the regex text for the shortcut finder.""" + if reset: + text = '' + else: + text = self.finder.text().replace(' ', '').lower() + + self.proxy_model.set_filter(text) + self.source_model.update_search_letters(text) + self.sortByColumn(SEARCH_SCORE, Qt.AscendingOrder) + + if self.last_regex != regex: + self.selectRow(0) + self.last_regex = regex + + def next_row(self): + """Move to next row from currently selected row.""" + row = self.currentIndex().row() + rows = self.proxy_model.rowCount() + if row + 1 == rows: + row = -1 + self.selectRow(row + 1) + + def previous_row(self): + """Move to previous row from currently selected row.""" + row = self.currentIndex().row() + rows = self.proxy_model.rowCount() + if row == 0: + row = rows + self.selectRow(row - 1) + + def keyPressEvent(self, event): + """Qt Override.""" + key = event.key() + if key in [Qt.Key_Enter, Qt.Key_Return]: + self.show_editor() + elif key in [Qt.Key_Tab]: + self.finder.setFocus() + elif key in [Qt.Key_Backtab]: + self.parent().reset_btn.setFocus() + elif key in [Qt.Key_Up, Qt.Key_Down, Qt.Key_Left, Qt.Key_Right]: + super(ShortcutsTable, self).keyPressEvent(event) + elif key not in [Qt.Key_Escape, Qt.Key_Space]: + text = event.text() + if text: + if re.search(VALID_FINDER_CHARS, text) is not None: + self.finder.setFocus() + self.finder.set_text(text) + elif key in [Qt.Key_Escape]: + self.finder.keyPressEvent(event) + + def mouseDoubleClickEvent(self, event): + """Qt Override.""" + self.show_editor() + self.update() + + +class ShortcutsSortFilterProxy(QSortFilterProxyModel): + """Custom proxy for supporting shortcuts multifiltering.""" + + def __init__(self, parent=None): + """Initialize the multiple sort filter proxy.""" + super(ShortcutsSortFilterProxy, self).__init__(parent) + self._parent = parent + self.pattern = re.compile(r'') + self.filters = {} + + def setFilterByColumn(self, column): + """Set regular expression in the given column.""" + self.filters[column] = self.pattern + self.invalidateFilter() + + def set_filter(self, text): + """Set regular expression for filter.""" + for key, __ in self.filters.items(): + self.pattern = get_search_regex(text) + if self.pattern and text: + self._parent.setSortingEnabled(False) + else: + self._parent.setSortingEnabled(True) + self.filters[key] = self.pattern + self.invalidateFilter() + + def clearFilter(self, column): + """Clear the filter of the given column.""" + self.filters.pop(column) + self.invalidateFilter() + + def clearFilters(self): + """Clear all the filters.""" + self.filters = {} + self.invalidateFilter() + + def filterAcceptsRow(self, row_num, parent): + """Qt override. + + Reimplemented to allow filtering in multiple columns. + """ + results = [] + for key, regex in self.filters.items(): + model = self.sourceModel() + idx = model.index(row_num, key, parent) + if idx.isValid(): + name = model.row(row_num).name + r_name = re.search(regex, name) + if r_name is None: + r_name = '' + context = model.row(row_num).context + r_context = re.search(regex, context) + if r_context is None: + r_context = '' + results.append(r_name) + results.append(r_context) + return any(results) + + +def load_shortcuts_data(): + """ + Load shortcuts from CONF for testing. + """ + shortcut_data = [] + for context, name, __ in CONF.iter_shortcuts(): + context = context.lower() + name = name.lower() + shortcut_data.append((None, context, name, None, None)) + return shortcut_data + + +def load_shortcuts(shortcut_table): + """ + Load shortcuts into `shortcut_table`. + """ + shortcut_data = load_shortcuts_data() + shortcut_table.set_shortcut_data(shortcut_data) + shortcut_table.load_shortcuts() + return shortcut_table + + +def test(): + from spyder.utils.qthelpers import qapplication + + app = qapplication() + table = ShortcutsTable() + table = load_shortcuts(table) + table.show() + app.exec_() + + table.check_shortcuts() + + +if __name__ == '__main__': + test() diff --git a/spyder/plugins/statusbar/container.py b/spyder/plugins/statusbar/container.py index af712e6862a..7196adcdc9e 100644 --- a/spyder/plugins/statusbar/container.py +++ b/spyder/plugins/statusbar/container.py @@ -1,65 +1,65 @@ -# -*- coding: utf-8 -*- -# -# Copyright © Spyder Project Contributors -# Licensed under the terms of the MIT License -# (see spyder/__init__.py for details) - -""" -Status bar container. -""" - -# Third-party imports -from qtpy.QtCore import Signal - -# Local imports -from spyder.api.config.decorators import on_conf_change -from spyder.api.widgets.main_container import PluginMainContainer -from spyder.plugins.statusbar.widgets.status import ( - ClockStatus, CPUStatus, MemoryStatus -) - - -class StatusBarContainer(PluginMainContainer): - - sig_show_status_bar_requested = Signal(bool) - """ - This signal is emmitted when the user wants to show/hide the - status bar. - """ - - def setup(self): - # Basic status widgets - self.mem_status = MemoryStatus(parent=self) - self.cpu_status = CPUStatus(parent=self) - self.clock_status = ClockStatus(parent=self) - - @on_conf_change(option='memory_usage/enable') - def enable_mem_status(self, value): - self.mem_status.setVisible(value) - - @on_conf_change(option='memory_usage/timeout') - def set_mem_interval(self, value): - self.mem_status.set_interval(value) - - @on_conf_change(option='cpu_usage/enable') - def enable_cpu_status(self, value): - self.cpu_status.setVisible(value) - - @on_conf_change(option='cpu_usage/timeout') - def set_cpu_interval(self, value): - self.cpu_status.set_interval(value) - - @on_conf_change(option='clock/enable') - def enable_clock_status(self, value): - self.clock_status.setVisible(value) - - @on_conf_change(option='clock/timeout') - def set_clock_interval(self, value): - self.clock_status.set_interval(value) - - @on_conf_change(option='show_status_bar') - def show_status_bar(self, value): - self.sig_show_status_bar_requested.emit(value) - - def update_actions(self): - pass +# -*- coding: utf-8 -*- +# +# Copyright © Spyder Project Contributors +# Licensed under the terms of the MIT License +# (see spyder/__init__.py for details) + +""" +Status bar container. +""" + +# Third-party imports +from qtpy.QtCore import Signal + +# Local imports +from spyder.api.config.decorators import on_conf_change +from spyder.api.widgets.main_container import PluginMainContainer +from spyder.plugins.statusbar.widgets.status import ( + ClockStatus, CPUStatus, MemoryStatus +) + + +class StatusBarContainer(PluginMainContainer): + + sig_show_status_bar_requested = Signal(bool) + """ + This signal is emmitted when the user wants to show/hide the + status bar. + """ + + def setup(self): + # Basic status widgets + self.mem_status = MemoryStatus(parent=self) + self.cpu_status = CPUStatus(parent=self) + self.clock_status = ClockStatus(parent=self) + + @on_conf_change(option='memory_usage/enable') + def enable_mem_status(self, value): + self.mem_status.setVisible(value) + + @on_conf_change(option='memory_usage/timeout') + def set_mem_interval(self, value): + self.mem_status.set_interval(value) + + @on_conf_change(option='cpu_usage/enable') + def enable_cpu_status(self, value): + self.cpu_status.setVisible(value) + + @on_conf_change(option='cpu_usage/timeout') + def set_cpu_interval(self, value): + self.cpu_status.set_interval(value) + + @on_conf_change(option='clock/enable') + def enable_clock_status(self, value): + self.clock_status.setVisible(value) + + @on_conf_change(option='clock/timeout') + def set_clock_interval(self, value): + self.clock_status.set_interval(value) + + @on_conf_change(option='show_status_bar') + def show_status_bar(self, value): + self.sig_show_status_bar_requested.emit(value) + + def update_actions(self): + pass diff --git a/spyder/plugins/statusbar/plugin.py b/spyder/plugins/statusbar/plugin.py index 0cb3097c4e1..b35d350727b 100644 --- a/spyder/plugins/statusbar/plugin.py +++ b/spyder/plugins/statusbar/plugin.py @@ -1,247 +1,247 @@ -# -*- coding: utf-8 -*- -# -# Copyright © Spyder Project Contributors -# Licensed under the terms of the MIT License -# (see spyder/__init__.py for details) - -""" -Status bar plugin. -""" - -# Third-party imports -from qtpy.QtCore import Slot - -# Local imports -from spyder.api.exceptions import SpyderAPIError -from spyder.api.plugins import Plugins, SpyderPluginV2 -from spyder.api.plugin_registration.decorators import ( - on_plugin_available, on_plugin_teardown) -from spyder.api.translations import get_translation -from spyder.api.widgets.status import StatusBarWidget -from spyder.config.base import running_under_pytest -from spyder.plugins.statusbar.confpage import StatusBarConfigPage -from spyder.plugins.statusbar.container import StatusBarContainer - - -# Localization -_ = get_translation('spyder') - - -class StatusBarWidgetPosition: - Left = 0 - Right = -1 - - -class StatusBar(SpyderPluginV2): - """Status bar plugin.""" - - NAME = 'statusbar' - REQUIRES = [Plugins.Preferences] - CONTAINER_CLASS = StatusBarContainer - CONF_SECTION = NAME - CONF_FILE = False - CONF_WIDGET_CLASS = StatusBarConfigPage - - STATUS_WIDGETS = {} - EXTERNAL_RIGHT_WIDGETS = {} - EXTERNAL_LEFT_WIDGETS = {} - INTERNAL_WIDGETS = {} - INTERNAL_WIDGETS_IDS = { - 'clock_status', 'cpu_status', 'memory_status', 'read_write_status', - 'eol_status', 'encoding_status', 'cursor_position_status', - 'vcs_status', 'lsp_status', 'kite_status', 'completion_status'} - - # ---- SpyderPluginV2 API - @staticmethod - def get_name(): - return _('Status bar') - - def get_icon(self): - return self.create_icon('statusbar') - - def get_description(self): - return _('Provide Core user interface management') - - def on_initialize(self): - # --- Status widgets - self.add_status_widget(self.mem_status, StatusBarWidgetPosition.Right) - self.add_status_widget(self.cpu_status, StatusBarWidgetPosition.Right) - self.add_status_widget( - self.clock_status, StatusBarWidgetPosition.Right) - - def on_close(self, _unused): - self._statusbar.setVisible(False) - - @on_plugin_available(plugin=Plugins.Preferences) - def on_preferences_available(self): - preferences = self.get_plugin(Plugins.Preferences) - preferences.register_plugin_preferences(self) - - @on_plugin_teardown(plugin=Plugins.Preferences) - def on_preferences_teardown(self): - preferences = self.get_plugin(Plugins.Preferences) - preferences.deregister_plugin_preferences(self) - - def after_container_creation(self): - container = self.get_container() - container.sig_show_status_bar_requested.connect( - self.show_status_bar - ) - - # ---- Public API - def add_status_widget(self, widget, position=StatusBarWidgetPosition.Left): - """ - Add status widget to main application status bar. - - Parameters - ---------- - widget: StatusBarWidget - Widget to be added to the status bar. - position: int - Position where the widget will be added given the members of the - StatusBarWidgetPosition enum. - """ - # Check widget class - if not isinstance(widget, StatusBarWidget): - raise SpyderAPIError( - 'Any status widget must subclass StatusBarWidget!' - ) - - # Check ID - id_ = widget.ID - if id_ is None: - raise SpyderAPIError( - f"Status widget `{repr(widget)}` doesn't have an identifier!" - ) - - # Check it was not added before - if id_ in self.STATUS_WIDGETS and not running_under_pytest(): - raise SpyderAPIError(f'Status widget `{id_}` already added!') - - if id_ in self.INTERNAL_WIDGETS_IDS: - self.INTERNAL_WIDGETS[id_] = widget - elif position == StatusBarWidgetPosition.Right: - self.EXTERNAL_RIGHT_WIDGETS[id_] = widget - else: - self.EXTERNAL_LEFT_WIDGETS[id_] = widget - - self.STATUS_WIDGETS[id_] = widget - self._statusbar.setStyleSheet('QStatusBar::item {border: None;}') - - if position == StatusBarWidgetPosition.Right: - self._statusbar.addPermanentWidget(widget) - else: - self._statusbar.insertPermanentWidget( - StatusBarWidgetPosition.Left, widget) - self._statusbar.layout().setContentsMargins(0, 0, 0, 0) - self._statusbar.layout().setSpacing(0) - - def remove_status_widget(self, id_): - """ - Remove widget from status bar. - - Parameters - ---------- - id_: str - String identifier for the widget. - """ - try: - widget = self.get_status_widget(id_) - self.STATUS_WIDGETS.pop(id_) - self._statusbar.removeWidget(widget) - except RuntimeError: - # This can happen if the widget was already removed (tests fail - # without this). - pass - - def get_status_widget(self, id_): - """ - Return an application status widget by name. - - Parameters - ---------- - id_: str - String identifier for the widget. - """ - if id_ in self.STATUS_WIDGETS: - return self.STATUS_WIDGETS[id_] - else: - raise SpyderAPIError(f'Status widget "{id_}" not found!') - - def get_status_widgets(self): - """Return all status widgets.""" - return list(self.STATUS_WIDGETS.keys()) - - def remove_status_widgets(self): - """Remove all status widgets.""" - for w in self.get_status_widgets(): - self.remove_status_widget(w) - - @Slot(bool) - def show_status_bar(self, value): - """ - Show/hide status bar. - - Parameters - ---------- - value: bool - Decide whether to show or hide the status bar. - """ - self._statusbar.setVisible(value) - - # ---- Default status widgets - @property - def mem_status(self): - return self.get_container().mem_status - - @property - def cpu_status(self): - return self.get_container().cpu_status - - @property - def clock_status(self): - return self.get_container().clock_status - - # ---- Private API - @property - def _statusbar(self): - """Reference to main window status bar.""" - return self._main.statusBar() - - def _organize_status_widgets(self): - """ - Organize the status bar widgets once the application is loaded. - """ - # Desired organization - internal_layout = [ - 'clock_status', 'cpu_status', 'memory_status', 'read_write_status', - 'eol_status', 'encoding_status', 'cursor_position_status', - 'vcs_status', 'lsp_status', 'kite_status', 'completion_status'] - external_left = list(self.EXTERNAL_LEFT_WIDGETS.keys()) - - # Remove all widgets from the statusbar, except the external right - for id_ in self.INTERNAL_WIDGETS: - self._statusbar.removeWidget(self.INTERNAL_WIDGETS[id_]) - - for id_ in self.EXTERNAL_LEFT_WIDGETS: - self._statusbar.removeWidget(self.EXTERNAL_LEFT_WIDGETS[id_]) - - # Add the internal widgets in the desired layout - for id_ in internal_layout: - # This is needed in the case kite is installed but not enabled - if id_ in self.INTERNAL_WIDGETS: - self._statusbar.insertPermanentWidget( - StatusBarWidgetPosition.Left, self.INTERNAL_WIDGETS[id_]) - self.INTERNAL_WIDGETS[id_].setVisible(True) - - # Add the external left widgets - for id_ in external_left: - self._statusbar.insertPermanentWidget( - StatusBarWidgetPosition.Left, self.EXTERNAL_LEFT_WIDGETS[id_]) - self.EXTERNAL_LEFT_WIDGETS[id_].setVisible(True) - - def before_mainwindow_visible(self): - """Perform actions before the mainwindow is visible""" - # Organize widgets in the expected order - self._statusbar.setVisible(False) - self._organize_status_widgets() +# -*- coding: utf-8 -*- +# +# Copyright © Spyder Project Contributors +# Licensed under the terms of the MIT License +# (see spyder/__init__.py for details) + +""" +Status bar plugin. +""" + +# Third-party imports +from qtpy.QtCore import Slot + +# Local imports +from spyder.api.exceptions import SpyderAPIError +from spyder.api.plugins import Plugins, SpyderPluginV2 +from spyder.api.plugin_registration.decorators import ( + on_plugin_available, on_plugin_teardown) +from spyder.api.translations import get_translation +from spyder.api.widgets.status import StatusBarWidget +from spyder.config.base import running_under_pytest +from spyder.plugins.statusbar.confpage import StatusBarConfigPage +from spyder.plugins.statusbar.container import StatusBarContainer + + +# Localization +_ = get_translation('spyder') + + +class StatusBarWidgetPosition: + Left = 0 + Right = -1 + + +class StatusBar(SpyderPluginV2): + """Status bar plugin.""" + + NAME = 'statusbar' + REQUIRES = [Plugins.Preferences] + CONTAINER_CLASS = StatusBarContainer + CONF_SECTION = NAME + CONF_FILE = False + CONF_WIDGET_CLASS = StatusBarConfigPage + + STATUS_WIDGETS = {} + EXTERNAL_RIGHT_WIDGETS = {} + EXTERNAL_LEFT_WIDGETS = {} + INTERNAL_WIDGETS = {} + INTERNAL_WIDGETS_IDS = { + 'clock_status', 'cpu_status', 'memory_status', 'read_write_status', + 'eol_status', 'encoding_status', 'cursor_position_status', + 'vcs_status', 'lsp_status', 'kite_status', 'completion_status'} + + # ---- SpyderPluginV2 API + @staticmethod + def get_name(): + return _('Status bar') + + def get_icon(self): + return self.create_icon('statusbar') + + def get_description(self): + return _('Provide Core user interface management') + + def on_initialize(self): + # --- Status widgets + self.add_status_widget(self.mem_status, StatusBarWidgetPosition.Right) + self.add_status_widget(self.cpu_status, StatusBarWidgetPosition.Right) + self.add_status_widget( + self.clock_status, StatusBarWidgetPosition.Right) + + def on_close(self, _unused): + self._statusbar.setVisible(False) + + @on_plugin_available(plugin=Plugins.Preferences) + def on_preferences_available(self): + preferences = self.get_plugin(Plugins.Preferences) + preferences.register_plugin_preferences(self) + + @on_plugin_teardown(plugin=Plugins.Preferences) + def on_preferences_teardown(self): + preferences = self.get_plugin(Plugins.Preferences) + preferences.deregister_plugin_preferences(self) + + def after_container_creation(self): + container = self.get_container() + container.sig_show_status_bar_requested.connect( + self.show_status_bar + ) + + # ---- Public API + def add_status_widget(self, widget, position=StatusBarWidgetPosition.Left): + """ + Add status widget to main application status bar. + + Parameters + ---------- + widget: StatusBarWidget + Widget to be added to the status bar. + position: int + Position where the widget will be added given the members of the + StatusBarWidgetPosition enum. + """ + # Check widget class + if not isinstance(widget, StatusBarWidget): + raise SpyderAPIError( + 'Any status widget must subclass StatusBarWidget!' + ) + + # Check ID + id_ = widget.ID + if id_ is None: + raise SpyderAPIError( + f"Status widget `{repr(widget)}` doesn't have an identifier!" + ) + + # Check it was not added before + if id_ in self.STATUS_WIDGETS and not running_under_pytest(): + raise SpyderAPIError(f'Status widget `{id_}` already added!') + + if id_ in self.INTERNAL_WIDGETS_IDS: + self.INTERNAL_WIDGETS[id_] = widget + elif position == StatusBarWidgetPosition.Right: + self.EXTERNAL_RIGHT_WIDGETS[id_] = widget + else: + self.EXTERNAL_LEFT_WIDGETS[id_] = widget + + self.STATUS_WIDGETS[id_] = widget + self._statusbar.setStyleSheet('QStatusBar::item {border: None;}') + + if position == StatusBarWidgetPosition.Right: + self._statusbar.addPermanentWidget(widget) + else: + self._statusbar.insertPermanentWidget( + StatusBarWidgetPosition.Left, widget) + self._statusbar.layout().setContentsMargins(0, 0, 0, 0) + self._statusbar.layout().setSpacing(0) + + def remove_status_widget(self, id_): + """ + Remove widget from status bar. + + Parameters + ---------- + id_: str + String identifier for the widget. + """ + try: + widget = self.get_status_widget(id_) + self.STATUS_WIDGETS.pop(id_) + self._statusbar.removeWidget(widget) + except RuntimeError: + # This can happen if the widget was already removed (tests fail + # without this). + pass + + def get_status_widget(self, id_): + """ + Return an application status widget by name. + + Parameters + ---------- + id_: str + String identifier for the widget. + """ + if id_ in self.STATUS_WIDGETS: + return self.STATUS_WIDGETS[id_] + else: + raise SpyderAPIError(f'Status widget "{id_}" not found!') + + def get_status_widgets(self): + """Return all status widgets.""" + return list(self.STATUS_WIDGETS.keys()) + + def remove_status_widgets(self): + """Remove all status widgets.""" + for w in self.get_status_widgets(): + self.remove_status_widget(w) + + @Slot(bool) + def show_status_bar(self, value): + """ + Show/hide status bar. + + Parameters + ---------- + value: bool + Decide whether to show or hide the status bar. + """ + self._statusbar.setVisible(value) + + # ---- Default status widgets + @property + def mem_status(self): + return self.get_container().mem_status + + @property + def cpu_status(self): + return self.get_container().cpu_status + + @property + def clock_status(self): + return self.get_container().clock_status + + # ---- Private API + @property + def _statusbar(self): + """Reference to main window status bar.""" + return self._main.statusBar() + + def _organize_status_widgets(self): + """ + Organize the status bar widgets once the application is loaded. + """ + # Desired organization + internal_layout = [ + 'clock_status', 'cpu_status', 'memory_status', 'read_write_status', + 'eol_status', 'encoding_status', 'cursor_position_status', + 'vcs_status', 'lsp_status', 'kite_status', 'completion_status'] + external_left = list(self.EXTERNAL_LEFT_WIDGETS.keys()) + + # Remove all widgets from the statusbar, except the external right + for id_ in self.INTERNAL_WIDGETS: + self._statusbar.removeWidget(self.INTERNAL_WIDGETS[id_]) + + for id_ in self.EXTERNAL_LEFT_WIDGETS: + self._statusbar.removeWidget(self.EXTERNAL_LEFT_WIDGETS[id_]) + + # Add the internal widgets in the desired layout + for id_ in internal_layout: + # This is needed in the case kite is installed but not enabled + if id_ in self.INTERNAL_WIDGETS: + self._statusbar.insertPermanentWidget( + StatusBarWidgetPosition.Left, self.INTERNAL_WIDGETS[id_]) + self.INTERNAL_WIDGETS[id_].setVisible(True) + + # Add the external left widgets + for id_ in external_left: + self._statusbar.insertPermanentWidget( + StatusBarWidgetPosition.Left, self.EXTERNAL_LEFT_WIDGETS[id_]) + self.EXTERNAL_LEFT_WIDGETS[id_].setVisible(True) + + def before_mainwindow_visible(self): + """Perform actions before the mainwindow is visible""" + # Organize widgets in the expected order + self._statusbar.setVisible(False) + self._organize_status_widgets() diff --git a/spyder/plugins/toolbar/container.py b/spyder/plugins/toolbar/container.py index 0dd73cf0c28..c94a7e9a41b 100644 --- a/spyder/plugins/toolbar/container.py +++ b/spyder/plugins/toolbar/container.py @@ -1,396 +1,396 @@ -# -*- coding: utf-8 -*- -# -# Copyright © Spyder Project Contributors -# Licensed under the terms of the MIT License -# (see spyder/__init__.py for details) - -""" -Toolbar Container. -""" - -# Standard library imports -from collections import OrderedDict -from spyder.utils.qthelpers import SpyderAction -from typing import Optional, Union, Tuple, Dict, List - -# Third party imports -from qtpy.QtCore import QSize, Slot -from qtpy.QtWidgets import QAction, QWidget -from qtpy import PYSIDE2 - -# Local imports -from spyder.api.exceptions import SpyderAPIError -from spyder.api.translations import get_translation -from spyder.api.widgets.main_container import PluginMainContainer -from spyder.api.utils import get_class_values -from spyder.api.widgets.toolbars import ApplicationToolbar -from spyder.plugins.toolbar.api import ApplicationToolbars -from spyder.utils.registries import TOOLBAR_REGISTRY - - -# Localization -_ = get_translation('spyder') - -# Type annotations -ToolbarItem = Union[SpyderAction, QWidget] -ItemInfo = Tuple[ToolbarItem, Optional[str], Optional[str], Optional[str]] - - -class ToolbarMenus: - ToolbarsMenu = "toolbars_menu" - - -class ToolbarsMenuSections: - Main = "main_section" - Secondary = "secondary_section" - - -class ToolbarActions: - ShowToolbars = "show toolbars" - - -class QActionID(QAction): - """Wrapper class around QAction that allows to set/get an identifier.""" - @property - def action_id(self): - return self._action_id - - @action_id.setter - def action_id(self, act): - self._action_id = act - - -class ToolbarContainer(PluginMainContainer): - def __init__(self, name, plugin, parent=None): - super().__init__(name, plugin, parent=parent) - - self._APPLICATION_TOOLBARS = OrderedDict() - self._ADDED_TOOLBARS = OrderedDict() - self._toolbarslist = [] - self._visible_toolbars = [] - self._ITEMS_QUEUE = {} # type: Dict[str, List[ItemInfo]] - - # ---- Private Methods - # ------------------------------------------------------------------------ - def _save_visible_toolbars(self): - """Save the name of the visible toolbars in the options.""" - toolbars = [] - for toolbar in self._visible_toolbars: - toolbars.append(toolbar.objectName()) - - self.set_conf('last_visible_toolbars', toolbars) - - def _get_visible_toolbars(self): - """Collect the visible toolbars.""" - toolbars = [] - for toolbar in self._toolbarslist: - if (toolbar.toggleViewAction().isChecked() - and toolbar not in toolbars): - toolbars.append(toolbar) - - self._visible_toolbars = toolbars - - @Slot() - def _show_toolbars(self): - """Show/Hide toolbars.""" - value = not self.get_conf("toolbars_visible") - self.set_conf("toolbars_visible", value) - if value: - self._save_visible_toolbars() - else: - self._get_visible_toolbars() - - for toolbar in self._visible_toolbars: - toolbar.setVisible(value) - - self.update_actions() - - def _add_missing_toolbar_elements(self, toolbar, toolbar_id): - if toolbar_id in self._ITEMS_QUEUE: - pending_items = self._ITEMS_QUEUE.pop(toolbar_id) - for item, section, before, before_section in pending_items: - toolbar.add_item(item, section=section, before=before, - before_section=before_section) - - # ---- PluginMainContainer API - # ------------------------------------------------------------------------ - def setup(self): - self.show_toolbars_action = self.create_action( - ToolbarActions.ShowToolbars, - text=_("Show toolbars"), - triggered=self._show_toolbars - ) - - self.toolbars_menu = self.create_menu( - ToolbarMenus.ToolbarsMenu, - _("Toolbars"), - ) - self.toolbars_menu.setObjectName('checkbox-padding') - - def update_actions(self): - visible_toolbars = self.get_conf("toolbars_visible") - if visible_toolbars: - text = _("Hide toolbars") - tip = _("Hide toolbars") - else: - text = _("Show toolbars") - tip = _("Show toolbars") - - self.show_toolbars_action.setText(text) - self.show_toolbars_action.setToolTip(tip) - self.toolbars_menu.setEnabled(visible_toolbars) - - # ---- Public API - # ------------------------------------------------------------------------ - def create_application_toolbar( - self, toolbar_id: str, title: str) -> ApplicationToolbar: - """ - Create an application toolbar and add it to the main window. - - Parameters - ---------- - toolbar_id: str - The toolbar unique identifier string. - title: str - The localized toolbar title to be displayed. - - Returns - ------- - spyder.api.widgets.toolbar.ApplicationToolbar - The created application toolbar. - """ - if toolbar_id in self._APPLICATION_TOOLBARS: - raise SpyderAPIError( - 'Toolbar with ID "{}" already added!'.format(toolbar_id)) - - toolbar = ApplicationToolbar(self, title) - toolbar.ID = toolbar_id - toolbar.setObjectName(toolbar_id) - - TOOLBAR_REGISTRY.register_reference( - toolbar, toolbar_id, self.PLUGIN_NAME, self.CONTEXT_NAME) - self._APPLICATION_TOOLBARS[toolbar_id] = toolbar - - self._add_missing_toolbar_elements(toolbar, toolbar_id) - return toolbar - - def add_application_toolbar(self, toolbar, mainwindow=None): - """ - Add toolbar to application toolbars. - - Parameters - ---------- - toolbar: spyder.api.widgets.toolbars.ApplicationToolbar - The application toolbar to add to the `mainwindow`. - mainwindow: QMainWindow - The main application window. - """ - # Check toolbar class - if not isinstance(toolbar, ApplicationToolbar): - raise SpyderAPIError( - 'Any toolbar must subclass ApplicationToolbar!' - ) - - # Check ID - toolbar_id = toolbar.ID - if toolbar_id is None: - raise SpyderAPIError( - f"Toolbar `{repr(toolbar)}` doesn't have an identifier!" - ) - - if toolbar_id in self._ADDED_TOOLBARS: - raise SpyderAPIError( - 'Toolbar with ID "{}" already added!'.format(toolbar_id)) - - # TODO: Make the icon size adjustable in Preferences later on. - iconsize = 24 - toolbar.setIconSize(QSize(iconsize, iconsize)) - toolbar.setObjectName(toolbar_id) - - self._ADDED_TOOLBARS[toolbar_id] = toolbar - self._toolbarslist.append(toolbar) - - if mainwindow: - mainwindow.addToolBar(toolbar) - - self._add_missing_toolbar_elements(toolbar, toolbar_id) - - def remove_application_toolbar(self, toolbar_id: str, mainwindow=None): - """ - Remove toolbar from application toolbars. - - Parameters - ---------- - toolbar: str - The application toolbar to remove from the `mainwindow`. - mainwindow: QMainWindow - The main application window. - """ - - if toolbar_id not in self._ADDED_TOOLBARS: - raise SpyderAPIError( - 'Toolbar with ID "{}" is not in the main window'.format( - toolbar_id)) - - toolbar = self._ADDED_TOOLBARS.pop(toolbar_id) - self._toolbarslist.remove(toolbar) - - if mainwindow: - mainwindow.removeToolBar(toolbar) - - def add_item_to_application_toolbar(self, - item: ToolbarItem, - toolbar_id: Optional[str] = None, - section: Optional[str] = None, - before: Optional[str] = None, - before_section: Optional[str] = None, - omit_id: bool = False): - """ - Add action or widget `item` to given application toolbar `section`. - - Parameters - ---------- - item: SpyderAction or QWidget - The item to add to the `toolbar`. - toolbar_id: str or None - The application toolbar unique string identifier. - section: str or None - The section id in which to insert the `item` on the `toolbar`. - before: str or None - Make the item appear before another given item. - before_section: str or None - Make the item defined section appear before another given section - (the section must be already defined). - omit_id: bool - If True, then the toolbar will check if the item to add declares an - id, False otherwise. This flag exists only for items added on - Spyder 4 plugins. Default: False - """ - if toolbar_id not in self._APPLICATION_TOOLBARS: - pending_items = self._ITEMS_QUEUE.get(toolbar_id, []) - pending_items.append((item, section, before, before_section)) - self._ITEMS_QUEUE[toolbar_id] = pending_items - else: - toolbar = self.get_application_toolbar(toolbar_id) - toolbar.add_item(item, section=section, before=before, - before_section=before_section, omit_id=omit_id) - - def remove_item_from_application_toolbar(self, item_id: str, - toolbar_id: Optional[str] = None): - """ - Remove action or widget from given application toolbar by id. - - Parameters - ---------- - item: str - The item to remove from the `toolbar`. - toolbar_id: str or None - The application toolbar unique string identifier. - """ - if toolbar_id not in self._APPLICATION_TOOLBARS: - raise SpyderAPIError( - '{} is not a valid toolbar_id'.format(toolbar_id)) - - toolbar = self.get_application_toolbar(toolbar_id) - toolbar.remove_item(item_id) - - def get_application_toolbar(self, toolbar_id: str) -> ApplicationToolbar: - """ - Return an application toolbar by toolbar_id. - - Parameters - ---------- - toolbar_id: str - The toolbar unique string identifier. - - Returns - ------- - spyder.api.widgets.toolbars.ApplicationToolbar - The application toolbar. - """ - if toolbar_id not in self._APPLICATION_TOOLBARS: - raise SpyderAPIError( - 'Application toolbar "{0}" not found! ' - 'Available toolbars are: {1}'.format( - toolbar_id, - list(self._APPLICATION_TOOLBARS.keys()) - ) - ) - - return self._APPLICATION_TOOLBARS[toolbar_id] - - def get_application_toolbars(self): - """ - Return all created application toolbars. - - Returns - ------- - list - The list of all the added application toolbars. - """ - return self._toolbarslist - - def save_last_visible_toolbars(self): - """Save the last visible toolbars state in our preferences.""" - if self.get_conf("toolbars_visible"): - self._get_visible_toolbars() - self._save_visible_toolbars() - - def load_last_visible_toolbars(self): - """Load the last visible toolbars from our preferences.""" - toolbars_names = self.get_conf('last_visible_toolbars') - toolbars_visible = self.get_conf("toolbars_visible") - - if toolbars_names: - toolbars_dict = {} - for toolbar in self._toolbarslist: - toolbars_dict[toolbar.objectName()] = toolbar - - toolbars = [] - for name in toolbars_names: - if name in toolbars_dict: - toolbars.append(toolbars_dict[name]) - - self._visible_toolbars = toolbars - else: - self._get_visible_toolbars() - - for toolbar in self._visible_toolbars: - toolbar.setVisible(toolbars_visible) - - self.update_actions() - - def create_toolbars_menu(self): - """ - Populate the toolbars menu inside the view application menu. - """ - main_section = ToolbarsMenuSections.Main - secondary_section = ToolbarsMenuSections.Secondary - default_toolbars = get_class_values(ApplicationToolbars) - - for toolbar_id, toolbar in self._ADDED_TOOLBARS.items(): - if toolbar: - action = toolbar.toggleViewAction() - if not PYSIDE2: - # Modifying __class__ of a QObject created by C++ [1] seems - # to invalidate the corresponding Python object when PySide - # is used (changing __class__ of a QObject created in - # Python seems to work). - # - # [1] There are Qt functions such as - # QToolBar.toggleViewAction(), QToolBar.addAction(QString) - # and QMainWindow.addToolbar(QString), which return a - # pointer to an already existing QObject. - action.__class__ = QActionID - action.action_id = f'toolbar_{toolbar_id}' - section = ( - main_section - if toolbar_id in default_toolbars - else secondary_section - ) - - self.add_item_to_menu( - action, - menu=self.toolbars_menu, - section=section, - ) +# -*- coding: utf-8 -*- +# +# Copyright © Spyder Project Contributors +# Licensed under the terms of the MIT License +# (see spyder/__init__.py for details) + +""" +Toolbar Container. +""" + +# Standard library imports +from collections import OrderedDict +from spyder.utils.qthelpers import SpyderAction +from typing import Optional, Union, Tuple, Dict, List + +# Third party imports +from qtpy.QtCore import QSize, Slot +from qtpy.QtWidgets import QAction, QWidget +from qtpy import PYSIDE2 + +# Local imports +from spyder.api.exceptions import SpyderAPIError +from spyder.api.translations import get_translation +from spyder.api.widgets.main_container import PluginMainContainer +from spyder.api.utils import get_class_values +from spyder.api.widgets.toolbars import ApplicationToolbar +from spyder.plugins.toolbar.api import ApplicationToolbars +from spyder.utils.registries import TOOLBAR_REGISTRY + + +# Localization +_ = get_translation('spyder') + +# Type annotations +ToolbarItem = Union[SpyderAction, QWidget] +ItemInfo = Tuple[ToolbarItem, Optional[str], Optional[str], Optional[str]] + + +class ToolbarMenus: + ToolbarsMenu = "toolbars_menu" + + +class ToolbarsMenuSections: + Main = "main_section" + Secondary = "secondary_section" + + +class ToolbarActions: + ShowToolbars = "show toolbars" + + +class QActionID(QAction): + """Wrapper class around QAction that allows to set/get an identifier.""" + @property + def action_id(self): + return self._action_id + + @action_id.setter + def action_id(self, act): + self._action_id = act + + +class ToolbarContainer(PluginMainContainer): + def __init__(self, name, plugin, parent=None): + super().__init__(name, plugin, parent=parent) + + self._APPLICATION_TOOLBARS = OrderedDict() + self._ADDED_TOOLBARS = OrderedDict() + self._toolbarslist = [] + self._visible_toolbars = [] + self._ITEMS_QUEUE = {} # type: Dict[str, List[ItemInfo]] + + # ---- Private Methods + # ------------------------------------------------------------------------ + def _save_visible_toolbars(self): + """Save the name of the visible toolbars in the options.""" + toolbars = [] + for toolbar in self._visible_toolbars: + toolbars.append(toolbar.objectName()) + + self.set_conf('last_visible_toolbars', toolbars) + + def _get_visible_toolbars(self): + """Collect the visible toolbars.""" + toolbars = [] + for toolbar in self._toolbarslist: + if (toolbar.toggleViewAction().isChecked() + and toolbar not in toolbars): + toolbars.append(toolbar) + + self._visible_toolbars = toolbars + + @Slot() + def _show_toolbars(self): + """Show/Hide toolbars.""" + value = not self.get_conf("toolbars_visible") + self.set_conf("toolbars_visible", value) + if value: + self._save_visible_toolbars() + else: + self._get_visible_toolbars() + + for toolbar in self._visible_toolbars: + toolbar.setVisible(value) + + self.update_actions() + + def _add_missing_toolbar_elements(self, toolbar, toolbar_id): + if toolbar_id in self._ITEMS_QUEUE: + pending_items = self._ITEMS_QUEUE.pop(toolbar_id) + for item, section, before, before_section in pending_items: + toolbar.add_item(item, section=section, before=before, + before_section=before_section) + + # ---- PluginMainContainer API + # ------------------------------------------------------------------------ + def setup(self): + self.show_toolbars_action = self.create_action( + ToolbarActions.ShowToolbars, + text=_("Show toolbars"), + triggered=self._show_toolbars + ) + + self.toolbars_menu = self.create_menu( + ToolbarMenus.ToolbarsMenu, + _("Toolbars"), + ) + self.toolbars_menu.setObjectName('checkbox-padding') + + def update_actions(self): + visible_toolbars = self.get_conf("toolbars_visible") + if visible_toolbars: + text = _("Hide toolbars") + tip = _("Hide toolbars") + else: + text = _("Show toolbars") + tip = _("Show toolbars") + + self.show_toolbars_action.setText(text) + self.show_toolbars_action.setToolTip(tip) + self.toolbars_menu.setEnabled(visible_toolbars) + + # ---- Public API + # ------------------------------------------------------------------------ + def create_application_toolbar( + self, toolbar_id: str, title: str) -> ApplicationToolbar: + """ + Create an application toolbar and add it to the main window. + + Parameters + ---------- + toolbar_id: str + The toolbar unique identifier string. + title: str + The localized toolbar title to be displayed. + + Returns + ------- + spyder.api.widgets.toolbar.ApplicationToolbar + The created application toolbar. + """ + if toolbar_id in self._APPLICATION_TOOLBARS: + raise SpyderAPIError( + 'Toolbar with ID "{}" already added!'.format(toolbar_id)) + + toolbar = ApplicationToolbar(self, title) + toolbar.ID = toolbar_id + toolbar.setObjectName(toolbar_id) + + TOOLBAR_REGISTRY.register_reference( + toolbar, toolbar_id, self.PLUGIN_NAME, self.CONTEXT_NAME) + self._APPLICATION_TOOLBARS[toolbar_id] = toolbar + + self._add_missing_toolbar_elements(toolbar, toolbar_id) + return toolbar + + def add_application_toolbar(self, toolbar, mainwindow=None): + """ + Add toolbar to application toolbars. + + Parameters + ---------- + toolbar: spyder.api.widgets.toolbars.ApplicationToolbar + The application toolbar to add to the `mainwindow`. + mainwindow: QMainWindow + The main application window. + """ + # Check toolbar class + if not isinstance(toolbar, ApplicationToolbar): + raise SpyderAPIError( + 'Any toolbar must subclass ApplicationToolbar!' + ) + + # Check ID + toolbar_id = toolbar.ID + if toolbar_id is None: + raise SpyderAPIError( + f"Toolbar `{repr(toolbar)}` doesn't have an identifier!" + ) + + if toolbar_id in self._ADDED_TOOLBARS: + raise SpyderAPIError( + 'Toolbar with ID "{}" already added!'.format(toolbar_id)) + + # TODO: Make the icon size adjustable in Preferences later on. + iconsize = 24 + toolbar.setIconSize(QSize(iconsize, iconsize)) + toolbar.setObjectName(toolbar_id) + + self._ADDED_TOOLBARS[toolbar_id] = toolbar + self._toolbarslist.append(toolbar) + + if mainwindow: + mainwindow.addToolBar(toolbar) + + self._add_missing_toolbar_elements(toolbar, toolbar_id) + + def remove_application_toolbar(self, toolbar_id: str, mainwindow=None): + """ + Remove toolbar from application toolbars. + + Parameters + ---------- + toolbar: str + The application toolbar to remove from the `mainwindow`. + mainwindow: QMainWindow + The main application window. + """ + + if toolbar_id not in self._ADDED_TOOLBARS: + raise SpyderAPIError( + 'Toolbar with ID "{}" is not in the main window'.format( + toolbar_id)) + + toolbar = self._ADDED_TOOLBARS.pop(toolbar_id) + self._toolbarslist.remove(toolbar) + + if mainwindow: + mainwindow.removeToolBar(toolbar) + + def add_item_to_application_toolbar(self, + item: ToolbarItem, + toolbar_id: Optional[str] = None, + section: Optional[str] = None, + before: Optional[str] = None, + before_section: Optional[str] = None, + omit_id: bool = False): + """ + Add action or widget `item` to given application toolbar `section`. + + Parameters + ---------- + item: SpyderAction or QWidget + The item to add to the `toolbar`. + toolbar_id: str or None + The application toolbar unique string identifier. + section: str or None + The section id in which to insert the `item` on the `toolbar`. + before: str or None + Make the item appear before another given item. + before_section: str or None + Make the item defined section appear before another given section + (the section must be already defined). + omit_id: bool + If True, then the toolbar will check if the item to add declares an + id, False otherwise. This flag exists only for items added on + Spyder 4 plugins. Default: False + """ + if toolbar_id not in self._APPLICATION_TOOLBARS: + pending_items = self._ITEMS_QUEUE.get(toolbar_id, []) + pending_items.append((item, section, before, before_section)) + self._ITEMS_QUEUE[toolbar_id] = pending_items + else: + toolbar = self.get_application_toolbar(toolbar_id) + toolbar.add_item(item, section=section, before=before, + before_section=before_section, omit_id=omit_id) + + def remove_item_from_application_toolbar(self, item_id: str, + toolbar_id: Optional[str] = None): + """ + Remove action or widget from given application toolbar by id. + + Parameters + ---------- + item: str + The item to remove from the `toolbar`. + toolbar_id: str or None + The application toolbar unique string identifier. + """ + if toolbar_id not in self._APPLICATION_TOOLBARS: + raise SpyderAPIError( + '{} is not a valid toolbar_id'.format(toolbar_id)) + + toolbar = self.get_application_toolbar(toolbar_id) + toolbar.remove_item(item_id) + + def get_application_toolbar(self, toolbar_id: str) -> ApplicationToolbar: + """ + Return an application toolbar by toolbar_id. + + Parameters + ---------- + toolbar_id: str + The toolbar unique string identifier. + + Returns + ------- + spyder.api.widgets.toolbars.ApplicationToolbar + The application toolbar. + """ + if toolbar_id not in self._APPLICATION_TOOLBARS: + raise SpyderAPIError( + 'Application toolbar "{0}" not found! ' + 'Available toolbars are: {1}'.format( + toolbar_id, + list(self._APPLICATION_TOOLBARS.keys()) + ) + ) + + return self._APPLICATION_TOOLBARS[toolbar_id] + + def get_application_toolbars(self): + """ + Return all created application toolbars. + + Returns + ------- + list + The list of all the added application toolbars. + """ + return self._toolbarslist + + def save_last_visible_toolbars(self): + """Save the last visible toolbars state in our preferences.""" + if self.get_conf("toolbars_visible"): + self._get_visible_toolbars() + self._save_visible_toolbars() + + def load_last_visible_toolbars(self): + """Load the last visible toolbars from our preferences.""" + toolbars_names = self.get_conf('last_visible_toolbars') + toolbars_visible = self.get_conf("toolbars_visible") + + if toolbars_names: + toolbars_dict = {} + for toolbar in self._toolbarslist: + toolbars_dict[toolbar.objectName()] = toolbar + + toolbars = [] + for name in toolbars_names: + if name in toolbars_dict: + toolbars.append(toolbars_dict[name]) + + self._visible_toolbars = toolbars + else: + self._get_visible_toolbars() + + for toolbar in self._visible_toolbars: + toolbar.setVisible(toolbars_visible) + + self.update_actions() + + def create_toolbars_menu(self): + """ + Populate the toolbars menu inside the view application menu. + """ + main_section = ToolbarsMenuSections.Main + secondary_section = ToolbarsMenuSections.Secondary + default_toolbars = get_class_values(ApplicationToolbars) + + for toolbar_id, toolbar in self._ADDED_TOOLBARS.items(): + if toolbar: + action = toolbar.toggleViewAction() + if not PYSIDE2: + # Modifying __class__ of a QObject created by C++ [1] seems + # to invalidate the corresponding Python object when PySide + # is used (changing __class__ of a QObject created in + # Python seems to work). + # + # [1] There are Qt functions such as + # QToolBar.toggleViewAction(), QToolBar.addAction(QString) + # and QMainWindow.addToolbar(QString), which return a + # pointer to an already existing QObject. + action.__class__ = QActionID + action.action_id = f'toolbar_{toolbar_id}' + section = ( + main_section + if toolbar_id in default_toolbars + else secondary_section + ) + + self.add_item_to_menu( + action, + menu=self.toolbars_menu, + section=section, + ) diff --git a/spyder/plugins/toolbar/plugin.py b/spyder/plugins/toolbar/plugin.py index 94eae2d41aa..b804e244c55 100644 --- a/spyder/plugins/toolbar/plugin.py +++ b/spyder/plugins/toolbar/plugin.py @@ -1,266 +1,266 @@ -# -*- coding: utf-8 -*- -# -# Copyright © Spyder Project Contributors -# Licensed under the terms of the MIT License -# (see spyder/__init__.py for details) - -""" -Toolbar Plugin. -""" - -# Standard library imports -from spyder.utils.qthelpers import SpyderAction -from typing import Union, Optional - -# Local imports -from spyder.api.exceptions import SpyderAPIError -from spyder.api.plugins import SpyderPluginV2, Plugins -from spyder.api.plugin_registration.decorators import ( - on_plugin_available, on_plugin_teardown) -from spyder.api.translations import get_translation -from spyder.plugins.mainmenu.api import ApplicationMenus, ViewMenuSections -from spyder.plugins.toolbar.api import ApplicationToolbars -from spyder.plugins.toolbar.container import ( - ToolbarContainer, ToolbarMenus, ToolbarActions) - -# Third-party imports -from qtpy.QtWidgets import QWidget - -# Localization -_ = get_translation('spyder') - - -class Toolbar(SpyderPluginV2): - """ - Docstrings viewer widget. - """ - NAME = 'toolbar' - OPTIONAL = [Plugins.MainMenu] - CONF_SECTION = NAME - CONF_FILE = False - CONTAINER_CLASS = ToolbarContainer - CAN_BE_DISABLED = False - - # --- SpyderDocakblePlugin API - # ----------------------------------------------------------------------- - @staticmethod - def get_name(): - return _('Toolbar') - - def get_description(self): - return _('Application toolbars management.') - - def get_icon(self): - return self.create_icon('help') - - def on_initialize(self): - create_app_toolbar = self.create_application_toolbar - create_app_toolbar(ApplicationToolbars.File, _("File toolbar")) - create_app_toolbar(ApplicationToolbars.Run, _("Run toolbar")) - create_app_toolbar(ApplicationToolbars.Debug, _("Debug toolbar")) - create_app_toolbar(ApplicationToolbars.Main, _("Main toolbar")) - - @on_plugin_available(plugin=Plugins.MainMenu) - def on_main_menu_available(self): - mainmenu = self.get_plugin(Plugins.MainMenu) - # View menu Toolbar section - mainmenu.add_item_to_application_menu( - self.toolbars_menu, - menu_id=ApplicationMenus.View, - section=ViewMenuSections.Toolbar, - before_section=ViewMenuSections.Layout) - mainmenu.add_item_to_application_menu( - self.show_toolbars_action, - menu_id=ApplicationMenus.View, - section=ViewMenuSections.Toolbar, - before_section=ViewMenuSections.Layout) - - @on_plugin_teardown(plugin=Plugins.MainMenu) - def on_main_menu_teardown(self): - mainmenu = self.get_plugin(Plugins.MainMenu) - # View menu Toolbar section - mainmenu.remove_item_from_application_menu( - ToolbarMenus.ToolbarsMenu, - menu_id=ApplicationMenus.View) - mainmenu.remove_item_from_application_menu( - ToolbarActions.ShowToolbars, - menu_id=ApplicationMenus.View) - - def on_mainwindow_visible(self): - container = self.get_container() - - # TODO: Until all core plugins are migrated, this is needed. - ACTION_MAP = { - ApplicationToolbars.File: self._main.file_toolbar_actions, - ApplicationToolbars.Debug: self._main.debug_toolbar_actions, - ApplicationToolbars.Run: self._main.run_toolbar_actions, - } - for toolbar in container.get_application_toolbars(): - toolbar_id = toolbar.ID - if toolbar_id in ACTION_MAP: - section = 0 - for item in ACTION_MAP[toolbar_id]: - if item is None: - section += 1 - continue - - self.add_item_to_application_toolbar( - item, - toolbar_id=toolbar_id, - section=str(section), - omit_id=True - ) - - toolbar._render() - - container.create_toolbars_menu() - container.load_last_visible_toolbars() - - def on_close(self, _unused): - container = self.get_container() - container.save_last_visible_toolbars() - for toolbar in container._visible_toolbars: - toolbar.setVisible(False) - - # --- Public API - # ------------------------------------------------------------------------ - def create_application_toolbar(self, toolbar_id, title): - """ - Create a Spyder application toolbar. - - Parameters - ---------- - toolbar_id: str - The toolbar unique identifier string. - title: str - The localized toolbar title to be displayed. - - Returns - ------- - spyder.api.widgets.toolbar.ApplicationToolbar - The created application toolbar. - """ - toolbar = self.get_container().create_application_toolbar( - toolbar_id, title) - self.add_application_toolbar(toolbar) - return toolbar - - def add_application_toolbar(self, toolbar): - """ - Add toolbar to application toolbars. - - This can be used to add a custom toolbar. The `WorkingDirectory` - plugin is an example of this. - - Parameters - ---------- - toolbar: spyder.api.widgets.toolbars.ApplicationToolbar - The application toolbar to add to the main window. - """ - self.get_container().add_application_toolbar(toolbar, self._main) - - def remove_application_toolbar(self, toolbar_id: str): - """ - Remove toolbar from the application toolbars. - - This can be used to remove a custom toolbar. The `WorkingDirectory` - plugin is an example of this. - - Parameters - ---------- - toolbar: str - The application toolbar to remove from the main window. - """ - self.get_container().remove_application_toolbar(toolbar_id, self._main) - - def add_item_to_application_toolbar(self, - item: Union[SpyderAction, QWidget], - toolbar_id: Optional[str] = None, - section: Optional[str] = None, - before: Optional[str] = None, - before_section: Optional[str] = None, - omit_id: bool = False): - """ - Add action or widget `item` to given application menu `section`. - - Parameters - ---------- - item: SpyderAction or QWidget - The item to add to the `toolbar`. - toolbar_id: str or None - The application toolbar unique string identifier. - section: str or None - The section id in which to insert the `item` on the `toolbar`. - before: str or None - Make the item appear before another given item. - before_section: str or None - Make the item defined section appear before another given section - (must be already defined). - omit_id: bool - If True, then the toolbar will check if the item to add declares an - id, False otherwise. This flag exists only for items added on - Spyder 4 plugins. Default: False - """ - if before is not None: - if not isinstance(before, str): - raise ValueError('before argument must be a str') - - return self.get_container().add_item_to_application_toolbar( - item, - toolbar_id=toolbar_id, - section=section, - before=before, - before_section=before_section, - omit_id=omit_id - ) - - def remove_item_from_application_toolbar(self, item_id: str, - toolbar_id: Optional[str] = None): - """ - Remove action or widget `item` from given application menu by id. - - Parameters - ---------- - item_id: str - The item to remove from the toolbar. - toolbar_id: str or None - The application toolbar unique string identifier. - """ - self.get_container().remove_item_from_application_toolbar( - item_id, - toolbar_id=toolbar_id - ) - - def get_application_toolbar(self, toolbar_id): - """ - Return an application toolbar by toolbar_id. - - Parameters - ---------- - toolbar_id: str - The toolbar unique string identifier. - - Returns - ------- - spyder.api.widgets.toolbars.ApplicationToolbar - The application toolbar. - """ - return self.get_container().get_application_toolbar(toolbar_id) - - def toggle_lock(self, value=None): - """Lock/Unlock toolbars.""" - for toolbar in self.toolbarslist: - toolbar.setMovable(not value) - - # --- Convenience properties, while all plugins migrate. - @property - def toolbars_menu(self): - return self.get_container().get_menu("toolbars_menu") - - @property - def show_toolbars_action(self): - return self.get_action("show toolbars") - - @property - def toolbarslist(self): - return self.get_container()._toolbarslist +# -*- coding: utf-8 -*- +# +# Copyright © Spyder Project Contributors +# Licensed under the terms of the MIT License +# (see spyder/__init__.py for details) + +""" +Toolbar Plugin. +""" + +# Standard library imports +from spyder.utils.qthelpers import SpyderAction +from typing import Union, Optional + +# Local imports +from spyder.api.exceptions import SpyderAPIError +from spyder.api.plugins import SpyderPluginV2, Plugins +from spyder.api.plugin_registration.decorators import ( + on_plugin_available, on_plugin_teardown) +from spyder.api.translations import get_translation +from spyder.plugins.mainmenu.api import ApplicationMenus, ViewMenuSections +from spyder.plugins.toolbar.api import ApplicationToolbars +from spyder.plugins.toolbar.container import ( + ToolbarContainer, ToolbarMenus, ToolbarActions) + +# Third-party imports +from qtpy.QtWidgets import QWidget + +# Localization +_ = get_translation('spyder') + + +class Toolbar(SpyderPluginV2): + """ + Docstrings viewer widget. + """ + NAME = 'toolbar' + OPTIONAL = [Plugins.MainMenu] + CONF_SECTION = NAME + CONF_FILE = False + CONTAINER_CLASS = ToolbarContainer + CAN_BE_DISABLED = False + + # --- SpyderDocakblePlugin API + # ----------------------------------------------------------------------- + @staticmethod + def get_name(): + return _('Toolbar') + + def get_description(self): + return _('Application toolbars management.') + + def get_icon(self): + return self.create_icon('help') + + def on_initialize(self): + create_app_toolbar = self.create_application_toolbar + create_app_toolbar(ApplicationToolbars.File, _("File toolbar")) + create_app_toolbar(ApplicationToolbars.Run, _("Run toolbar")) + create_app_toolbar(ApplicationToolbars.Debug, _("Debug toolbar")) + create_app_toolbar(ApplicationToolbars.Main, _("Main toolbar")) + + @on_plugin_available(plugin=Plugins.MainMenu) + def on_main_menu_available(self): + mainmenu = self.get_plugin(Plugins.MainMenu) + # View menu Toolbar section + mainmenu.add_item_to_application_menu( + self.toolbars_menu, + menu_id=ApplicationMenus.View, + section=ViewMenuSections.Toolbar, + before_section=ViewMenuSections.Layout) + mainmenu.add_item_to_application_menu( + self.show_toolbars_action, + menu_id=ApplicationMenus.View, + section=ViewMenuSections.Toolbar, + before_section=ViewMenuSections.Layout) + + @on_plugin_teardown(plugin=Plugins.MainMenu) + def on_main_menu_teardown(self): + mainmenu = self.get_plugin(Plugins.MainMenu) + # View menu Toolbar section + mainmenu.remove_item_from_application_menu( + ToolbarMenus.ToolbarsMenu, + menu_id=ApplicationMenus.View) + mainmenu.remove_item_from_application_menu( + ToolbarActions.ShowToolbars, + menu_id=ApplicationMenus.View) + + def on_mainwindow_visible(self): + container = self.get_container() + + # TODO: Until all core plugins are migrated, this is needed. + ACTION_MAP = { + ApplicationToolbars.File: self._main.file_toolbar_actions, + ApplicationToolbars.Debug: self._main.debug_toolbar_actions, + ApplicationToolbars.Run: self._main.run_toolbar_actions, + } + for toolbar in container.get_application_toolbars(): + toolbar_id = toolbar.ID + if toolbar_id in ACTION_MAP: + section = 0 + for item in ACTION_MAP[toolbar_id]: + if item is None: + section += 1 + continue + + self.add_item_to_application_toolbar( + item, + toolbar_id=toolbar_id, + section=str(section), + omit_id=True + ) + + toolbar._render() + + container.create_toolbars_menu() + container.load_last_visible_toolbars() + + def on_close(self, _unused): + container = self.get_container() + container.save_last_visible_toolbars() + for toolbar in container._visible_toolbars: + toolbar.setVisible(False) + + # --- Public API + # ------------------------------------------------------------------------ + def create_application_toolbar(self, toolbar_id, title): + """ + Create a Spyder application toolbar. + + Parameters + ---------- + toolbar_id: str + The toolbar unique identifier string. + title: str + The localized toolbar title to be displayed. + + Returns + ------- + spyder.api.widgets.toolbar.ApplicationToolbar + The created application toolbar. + """ + toolbar = self.get_container().create_application_toolbar( + toolbar_id, title) + self.add_application_toolbar(toolbar) + return toolbar + + def add_application_toolbar(self, toolbar): + """ + Add toolbar to application toolbars. + + This can be used to add a custom toolbar. The `WorkingDirectory` + plugin is an example of this. + + Parameters + ---------- + toolbar: spyder.api.widgets.toolbars.ApplicationToolbar + The application toolbar to add to the main window. + """ + self.get_container().add_application_toolbar(toolbar, self._main) + + def remove_application_toolbar(self, toolbar_id: str): + """ + Remove toolbar from the application toolbars. + + This can be used to remove a custom toolbar. The `WorkingDirectory` + plugin is an example of this. + + Parameters + ---------- + toolbar: str + The application toolbar to remove from the main window. + """ + self.get_container().remove_application_toolbar(toolbar_id, self._main) + + def add_item_to_application_toolbar(self, + item: Union[SpyderAction, QWidget], + toolbar_id: Optional[str] = None, + section: Optional[str] = None, + before: Optional[str] = None, + before_section: Optional[str] = None, + omit_id: bool = False): + """ + Add action or widget `item` to given application menu `section`. + + Parameters + ---------- + item: SpyderAction or QWidget + The item to add to the `toolbar`. + toolbar_id: str or None + The application toolbar unique string identifier. + section: str or None + The section id in which to insert the `item` on the `toolbar`. + before: str or None + Make the item appear before another given item. + before_section: str or None + Make the item defined section appear before another given section + (must be already defined). + omit_id: bool + If True, then the toolbar will check if the item to add declares an + id, False otherwise. This flag exists only for items added on + Spyder 4 plugins. Default: False + """ + if before is not None: + if not isinstance(before, str): + raise ValueError('before argument must be a str') + + return self.get_container().add_item_to_application_toolbar( + item, + toolbar_id=toolbar_id, + section=section, + before=before, + before_section=before_section, + omit_id=omit_id + ) + + def remove_item_from_application_toolbar(self, item_id: str, + toolbar_id: Optional[str] = None): + """ + Remove action or widget `item` from given application menu by id. + + Parameters + ---------- + item_id: str + The item to remove from the toolbar. + toolbar_id: str or None + The application toolbar unique string identifier. + """ + self.get_container().remove_item_from_application_toolbar( + item_id, + toolbar_id=toolbar_id + ) + + def get_application_toolbar(self, toolbar_id): + """ + Return an application toolbar by toolbar_id. + + Parameters + ---------- + toolbar_id: str + The toolbar unique string identifier. + + Returns + ------- + spyder.api.widgets.toolbars.ApplicationToolbar + The application toolbar. + """ + return self.get_container().get_application_toolbar(toolbar_id) + + def toggle_lock(self, value=None): + """Lock/Unlock toolbars.""" + for toolbar in self.toolbarslist: + toolbar.setMovable(not value) + + # --- Convenience properties, while all plugins migrate. + @property + def toolbars_menu(self): + return self.get_container().get_menu("toolbars_menu") + + @property + def show_toolbars_action(self): + return self.get_action("show toolbars") + + @property + def toolbarslist(self): + return self.get_container()._toolbarslist diff --git a/spyder/plugins/tours/container.py b/spyder/plugins/tours/container.py index eb730f5ce3a..5458336379d 100644 --- a/spyder/plugins/tours/container.py +++ b/spyder/plugins/tours/container.py @@ -1,114 +1,114 @@ -# -*- coding: utf-8 -*- -# ----------------------------------------------------------------------------- -# Copyright (c) 2009- Spyder Project Contributors -# -# Distributed under the terms of the MIT License -# (see spyder/__init__.py for details) -# ----------------------------------------------------------------------------- - -""" -Tours Container. -""" - -from collections import OrderedDict - -# Local imports -from spyder.api.exceptions import SpyderAPIError -from spyder.api.translations import get_translation -from spyder.api.widgets.main_container import PluginMainContainer -from spyder.plugins.tours.tours import TourIdentifiers -from spyder.plugins.tours.widgets import AnimatedTour, OpenTourDialog - -# Localization -_ = get_translation('spyder') - -# Set the index for the default tour -DEFAULT_TOUR = TourIdentifiers.IntroductionTour - - -class TourActions: - """ - Tours actions. - """ - ShowTour = "show tour" - - -class ToursContainer(PluginMainContainer): - """ - Tours container. - """ - - def __init__(self, name, plugin, parent=None): - super().__init__(name, plugin, parent=parent) - - self._main = plugin.main - self._tours = OrderedDict() - self._tour_titles = OrderedDict() - self._tour_widget = AnimatedTour(self._main) - self._tour_dialog = OpenTourDialog( - self, lambda: self.show_tour(DEFAULT_TOUR)) - self.tour_action = self.create_action( - TourActions.ShowTour, - text=_("Show tour"), - icon=self.create_icon('tour'), - triggered=lambda: self.show_tour(DEFAULT_TOUR) - ) - - # --- PluginMainContainer API - # ------------------------------------------------------------------------ - def setup(self): - self.tours_menu = self.create_menu( - "tours_menu", _("Interactive tours")) - - def update_actions(self): - pass - - # --- Public API - # ------------------------------------------------------------------------ - def register_tour(self, tour_id, title, tour_data): - """ - Register a new interactive tour on spyder. - - Parameters - ---------- - tour_id: str - Unique tour string identifier. - title: str - Localized tour name. - tour_data: dict - The tour steps. - """ - if tour_id in self._tours: - raise SpyderAPIError( - "Tour with id '{}' has already been registered!".format( - tour_id)) - - self._tours[tour_id] = tour_data - self._tour_titles[tour_id] = title - action = self.create_action( - tour_id, - text=title, - triggered=lambda: self.show_tour(tour_id), - ) - self.add_item_to_menu(action, menu=self.tours_menu) - - def show_tour(self, tour_id): - """ - Show interactive tour. - - Parameters - ---------- - tour_id: str - Unique tour string identifier. - """ - tour_data = self._tours[tour_id] - dic = {'last': 0, 'tour': tour_data} - self._tour_widget.set_tour(tour_id, dic, self._main) - self._tour_widget.start_tour() - - def show_tour_message(self): - """ - Show message about starting the tour the first time Spyder starts. - """ - self._tour_dialog.show() - self._tour_dialog.raise_() +# -*- coding: utf-8 -*- +# ----------------------------------------------------------------------------- +# Copyright (c) 2009- Spyder Project Contributors +# +# Distributed under the terms of the MIT License +# (see spyder/__init__.py for details) +# ----------------------------------------------------------------------------- + +""" +Tours Container. +""" + +from collections import OrderedDict + +# Local imports +from spyder.api.exceptions import SpyderAPIError +from spyder.api.translations import get_translation +from spyder.api.widgets.main_container import PluginMainContainer +from spyder.plugins.tours.tours import TourIdentifiers +from spyder.plugins.tours.widgets import AnimatedTour, OpenTourDialog + +# Localization +_ = get_translation('spyder') + +# Set the index for the default tour +DEFAULT_TOUR = TourIdentifiers.IntroductionTour + + +class TourActions: + """ + Tours actions. + """ + ShowTour = "show tour" + + +class ToursContainer(PluginMainContainer): + """ + Tours container. + """ + + def __init__(self, name, plugin, parent=None): + super().__init__(name, plugin, parent=parent) + + self._main = plugin.main + self._tours = OrderedDict() + self._tour_titles = OrderedDict() + self._tour_widget = AnimatedTour(self._main) + self._tour_dialog = OpenTourDialog( + self, lambda: self.show_tour(DEFAULT_TOUR)) + self.tour_action = self.create_action( + TourActions.ShowTour, + text=_("Show tour"), + icon=self.create_icon('tour'), + triggered=lambda: self.show_tour(DEFAULT_TOUR) + ) + + # --- PluginMainContainer API + # ------------------------------------------------------------------------ + def setup(self): + self.tours_menu = self.create_menu( + "tours_menu", _("Interactive tours")) + + def update_actions(self): + pass + + # --- Public API + # ------------------------------------------------------------------------ + def register_tour(self, tour_id, title, tour_data): + """ + Register a new interactive tour on spyder. + + Parameters + ---------- + tour_id: str + Unique tour string identifier. + title: str + Localized tour name. + tour_data: dict + The tour steps. + """ + if tour_id in self._tours: + raise SpyderAPIError( + "Tour with id '{}' has already been registered!".format( + tour_id)) + + self._tours[tour_id] = tour_data + self._tour_titles[tour_id] = title + action = self.create_action( + tour_id, + text=title, + triggered=lambda: self.show_tour(tour_id), + ) + self.add_item_to_menu(action, menu=self.tours_menu) + + def show_tour(self, tour_id): + """ + Show interactive tour. + + Parameters + ---------- + tour_id: str + Unique tour string identifier. + """ + tour_data = self._tours[tour_id] + dic = {'last': 0, 'tour': tour_data} + self._tour_widget.set_tour(tour_id, dic, self._main) + self._tour_widget.start_tour() + + def show_tour_message(self): + """ + Show message about starting the tour the first time Spyder starts. + """ + self._tour_dialog.show() + self._tour_dialog.raise_() diff --git a/spyder/plugins/tours/plugin.py b/spyder/plugins/tours/plugin.py index 59fbfe68927..e7cd2770d10 100644 --- a/spyder/plugins/tours/plugin.py +++ b/spyder/plugins/tours/plugin.py @@ -1,121 +1,121 @@ -# -*- coding: utf-8 -*- -# ----------------------------------------------------------------------------- -# Copyright (c) 2009- Spyder Project Contributors -# -# Distributed under the terms of the MIT License -# (see spyder/__init__.py for details) -# ----------------------------------------------------------------------------- - -""" -Tours Plugin. -""" - -# Local imports -from spyder.api.plugins import Plugins, SpyderPluginV2 -from spyder.api.plugin_registration.decorators import ( - on_plugin_available, on_plugin_teardown) -from spyder.api.translations import get_translation -from spyder.config.base import get_safe_mode, running_under_pytest -from spyder.plugins.application.api import ApplicationActions -from spyder.plugins.tours.container import TourActions, ToursContainer -from spyder.plugins.tours.tours import INTRO_TOUR, TourIdentifiers -from spyder.plugins.mainmenu.api import ApplicationMenus, HelpMenuSections - -# Localization -_ = get_translation('spyder') - - -# --- Plugin -# ---------------------------------------------------------------------------- -class Tours(SpyderPluginV2): - """ - Tours Plugin. - """ - NAME = 'tours' - CONF_SECTION = NAME - OPTIONAL = [Plugins.MainMenu] - CONF_FILE = False - CONTAINER_CLASS = ToursContainer - - # --- SpyderPluginV2 API - # ------------------------------------------------------------------------ - @staticmethod - def get_name(): - return _("Interactive tours") - - def get_description(self): - return _("Provide interactive tours.") - - def get_icon(self): - return self.create_icon('tour') - - def on_initialize(self): - self.register_tour( - TourIdentifiers.IntroductionTour, - _("Introduction to Spyder"), - INTRO_TOUR, - ) - - @on_plugin_available(plugin=Plugins.MainMenu) - def on_main_menu_available(self): - mainmenu = self.get_plugin(Plugins.MainMenu) - - mainmenu.add_item_to_application_menu( - self.get_container().tour_action, - menu_id=ApplicationMenus.Help, - section=HelpMenuSections.Documentation, - before=ApplicationActions.SpyderDocumentationAction) - - @on_plugin_teardown(plugin=Plugins.MainMenu) - def on_main_menu_teardown(self): - mainmenu = self.get_plugin(Plugins.MainMenu) - mainmenu.remove_item_from_application_menu( - TourActions.ShowTour, - menu_id=ApplicationMenus.Help) - - def on_mainwindow_visible(self): - self.show_tour_message() - - # --- Public API - # ------------------------------------------------------------------------ - def register_tour(self, tour_id, title, tour_data): - """ - Register a new interactive tour on spyder. - - Parameters - ---------- - tour_id: str - Unique tour string identifier. - title: str - Localized tour name. - tour_data: dict - The tour steps. - """ - self.get_container().register_tour(tour_id, title, tour_data) - - def show_tour(self, index): - """ - Show interactive tour. - - Parameters - ---------- - index: int - The tour index to display. - """ - self.main.maximize_dockwidget(restore=True) - self.get_container().show_tour(index) - - def show_tour_message(self, force=False): - """ - Show message about starting the tour the first time Spyder starts. - - Parameters - ---------- - force: bool - Force the display of the tour message. - """ - should_show_tour = self.get_conf('show_tour_message') - if force or (should_show_tour and not running_under_pytest() - and not get_safe_mode()): - self.set_conf('show_tour_message', False) - self.get_container().show_tour_message() +# -*- coding: utf-8 -*- +# ----------------------------------------------------------------------------- +# Copyright (c) 2009- Spyder Project Contributors +# +# Distributed under the terms of the MIT License +# (see spyder/__init__.py for details) +# ----------------------------------------------------------------------------- + +""" +Tours Plugin. +""" + +# Local imports +from spyder.api.plugins import Plugins, SpyderPluginV2 +from spyder.api.plugin_registration.decorators import ( + on_plugin_available, on_plugin_teardown) +from spyder.api.translations import get_translation +from spyder.config.base import get_safe_mode, running_under_pytest +from spyder.plugins.application.api import ApplicationActions +from spyder.plugins.tours.container import TourActions, ToursContainer +from spyder.plugins.tours.tours import INTRO_TOUR, TourIdentifiers +from spyder.plugins.mainmenu.api import ApplicationMenus, HelpMenuSections + +# Localization +_ = get_translation('spyder') + + +# --- Plugin +# ---------------------------------------------------------------------------- +class Tours(SpyderPluginV2): + """ + Tours Plugin. + """ + NAME = 'tours' + CONF_SECTION = NAME + OPTIONAL = [Plugins.MainMenu] + CONF_FILE = False + CONTAINER_CLASS = ToursContainer + + # --- SpyderPluginV2 API + # ------------------------------------------------------------------------ + @staticmethod + def get_name(): + return _("Interactive tours") + + def get_description(self): + return _("Provide interactive tours.") + + def get_icon(self): + return self.create_icon('tour') + + def on_initialize(self): + self.register_tour( + TourIdentifiers.IntroductionTour, + _("Introduction to Spyder"), + INTRO_TOUR, + ) + + @on_plugin_available(plugin=Plugins.MainMenu) + def on_main_menu_available(self): + mainmenu = self.get_plugin(Plugins.MainMenu) + + mainmenu.add_item_to_application_menu( + self.get_container().tour_action, + menu_id=ApplicationMenus.Help, + section=HelpMenuSections.Documentation, + before=ApplicationActions.SpyderDocumentationAction) + + @on_plugin_teardown(plugin=Plugins.MainMenu) + def on_main_menu_teardown(self): + mainmenu = self.get_plugin(Plugins.MainMenu) + mainmenu.remove_item_from_application_menu( + TourActions.ShowTour, + menu_id=ApplicationMenus.Help) + + def on_mainwindow_visible(self): + self.show_tour_message() + + # --- Public API + # ------------------------------------------------------------------------ + def register_tour(self, tour_id, title, tour_data): + """ + Register a new interactive tour on spyder. + + Parameters + ---------- + tour_id: str + Unique tour string identifier. + title: str + Localized tour name. + tour_data: dict + The tour steps. + """ + self.get_container().register_tour(tour_id, title, tour_data) + + def show_tour(self, index): + """ + Show interactive tour. + + Parameters + ---------- + index: int + The tour index to display. + """ + self.main.maximize_dockwidget(restore=True) + self.get_container().show_tour(index) + + def show_tour_message(self, force=False): + """ + Show message about starting the tour the first time Spyder starts. + + Parameters + ---------- + force: bool + Force the display of the tour message. + """ + should_show_tour = self.get_conf('show_tour_message') + if force or (should_show_tour and not running_under_pytest() + and not get_safe_mode()): + self.set_conf('show_tour_message', False) + self.get_container().show_tour_message() diff --git a/spyder/plugins/tours/tours.py b/spyder/plugins/tours/tours.py index 632ffc32c97..1885dbffd9f 100644 --- a/spyder/plugins/tours/tours.py +++ b/spyder/plugins/tours/tours.py @@ -1,234 +1,234 @@ -# -*- coding: utf-8 -*- -# -# Copyright © Spyder Project Contributors -# Licensed under the terms of the MIT License -# (see spyder/__init__.py for details) - -"""Default tours.""" - -# Standard library imports -import sys - -# Local imports -from spyder.api.translations import get_translation -from spyder.plugins.tours.api import SpyderWidgets as sw -from spyder import __docs_url__ - -# Localization -_ = get_translation('spyder') - -# Constants -QTCONSOLE_LINK = "https://qtconsole.readthedocs.io/en/stable/index.html" -BUTTON_TEXT = "" -if sys.platform != "darwin": - BUTTON_TEXT = ("Please click on the button below to run some simple " - "code in this console. This will be useful to show " - "you other important features.") - -# This test should serve as example of keys to use in the tour frame dicts -TEST_TOUR = [ - { - 'title': "Welcome to Spyder introduction tour", - 'content': "Spyder is an interactive development " - "environment. This tip panel supports rich text.
    " - "
    it also supports image insertion to the right so far", - 'image': 'spyder_about', - }, - { - 'title': "Widget display", - 'content': ("This shows how a widget is displayed. The tip panel " - "is adjusted based on the first widget in the list"), - 'widgets': ['button1'], - 'decoration': ['button2'], - 'interact': True, - }, - { - 'title': "Widget display", - 'content': ("This shows how a widget is displayed. The tip panel " - "is adjusted based on the first widget in the list"), - 'widgets': ['button1'], - 'decoration': ['button1'], - 'interact': True, - }, - { - 'title': "Widget display", - 'content': ("This shows how a widget is displayed. The tip panel " - "is adjusted based on the first widget in the list"), - 'widgets': ['button1'], - 'interact': True, - }, - { - 'title': "Widget display and highlight", - 'content': "This shows how a highlighted widget looks", - 'widgets': ['button'], - 'decoration': ['button'], - 'interact': False, - }, -] - - -INTRO_TOUR = [ - { - 'title': _("Welcome to the introduction tour!"), - 'content': _("Spyder is a powerful Interactive " - "Development Environment (or IDE) for the Python " - "programming language.

    " - "Here, we are going to guide you through its most " - "important features.

    " - "Please use the arrow keys or click on the buttons " - "below to move along the tour."), - 'image': 'spyder_about', - }, - { - 'title': _("Editor"), - 'content': _("This is where you write Python code before " - "evaluating it. You can get automatic " - "completions while typing, along with calltips " - "when calling a function and help when hovering " - "over an object." - "

    The Editor comes " - "with a line number area (highlighted here in red) " - "where Spyder shows warnings and syntax errors. " - "They can help you to detect potential problems " - "before running your code.

    " - "You can also set debug breakpoints in the line " - "number area by clicking next to " - "any non-empty line."), - 'widgets': [sw.editor], - 'decoration': [sw.editor_line_number_area], - }, - { - 'title': _("IPython Console"), - 'content': _("This is where you can run Python code, either " - "from the Editor or interactively. To run the " - "current file, press F5 by default, " - "or press F9 to execute the current " - "line or selection.

    " - "The IPython Console comes with many " - "useful features that greatly improve your " - "programming workflow, like syntax highlighting, " - "autocompletion, plotting and 'magic' commands. " - "To learn more, check out the " - "documentation." - "

    {1}").format(QTCONSOLE_LINK, BUTTON_TEXT), - 'widgets': [sw.ipython_console], - 'run': [ - "test_list_tour = [1, 2, 3, 4, 5]", - "test_dict_tour = {'a': 1, 'b': 2}", - ], - }, - { - 'title': _("Variable Explorer"), - 'content': _("In this pane you can view and edit the variables " - "generated during the execution of a program, or " - "those entered directly in the " - "IPython Console.

    " - "If you ran the code in the previous step, " - "the Variable Explorer will show " - "the list and dictionary objects it generated. " - "By double-clicking any variable, " - "a new window will be opened where you " - "can inspect and modify their contents."), - 'widgets': [sw.variable_explorer], - 'interact': True, - }, - { - 'title': _("Help"), - 'content': _("This pane displays documentation of the " - "functions, classes, methods or modules you are " - "currently using in the Editor or the " - "IPython Console." - "

    To use it, press Ctrl+I " - "(Cmd-I on macOS) with the text cursor " - "in or next to the object you want help on."), - 'widgets': [sw.help_plugin], - 'interact': True, - }, - { - 'title': _("Plots"), - 'content': _("This pane shows the figures and images created " - "during your code execution. It allows you to browse, " - "zoom, copy, and save the generated plots."), - 'widgets': [sw.plots_plugin], - 'interact': True, - }, - { - 'title': _("Files"), - 'content': _("This pane lets you browse the files and " - "directories on your computer.

    " - "You can open any file in its " - "corresponding application by double-clicking it, " - "and supported file types will be opened right " - "inside of Spyder.

    " - "The Files pane also allows you to copy one or " - "many absolute or relative paths, automatically " - "formatted as Python strings or lists, and perform " - "a variety of other file operations."), - 'widgets': [sw.file_explorer], - 'interact': True, - }, - { - 'title': _("History Log"), - 'content': _("This pane records all the commands and code run " - "in any IPython console, allowing you to easily " - "retrace your steps for reproducible research."), - 'widgets': [sw.history_log], - 'interact': True, - }, - { - 'title': _("Find"), - 'content': _("The Find pane allows you to search for text in a " - "given directory and navigate through all the found " - "occurrences."), - 'widgets': [sw.find_plugin], - 'interact': True, - }, - { - 'title': _("Profiler"), - 'content': _("The Profiler helps you optimize your code by " - "determining the run time and number of calls for " - "every function and method used in a file. It also " - "allows you to save and compare your results between " - "runs."), - 'widgets': [sw.profiler], - 'interact': True, - }, - { - 'title': _("Code Analysis"), - 'content': _("The Code Analysis helps you improve the quality of " - "your programs by detecting style issues, bad practices " - "and potential bugs."), - 'widgets': [sw.code_analysis], - 'interact': True - }, - { - 'title': _("The end"), - 'content': _('You have reached the end of our tour and are ' - 'ready to start using Spyder! For more ' - 'information, check out our ' - 'documentation.' - '

    ').format(__docs_url__), - 'image': 'spyder_about' - }, -] - - -FEAT30 = [ - { - 'title': _("New features in Spyder 3.0"), - 'content': _("Spyder is an interactive development " - "environment based on bla"), - 'image': 'spyder_about', - }, - { - 'title': _("Welcome to Spyder introduction tour"), - 'content': _("Spyder is an interactive development environment " - "based on bla"), - 'widgets': ['variableexplorer'], - }, -] - - -class TourIdentifiers: - IntroductionTour = "introduction_tour" - TestTour = "test_tour" +# -*- coding: utf-8 -*- +# +# Copyright © Spyder Project Contributors +# Licensed under the terms of the MIT License +# (see spyder/__init__.py for details) + +"""Default tours.""" + +# Standard library imports +import sys + +# Local imports +from spyder.api.translations import get_translation +from spyder.plugins.tours.api import SpyderWidgets as sw +from spyder import __docs_url__ + +# Localization +_ = get_translation('spyder') + +# Constants +QTCONSOLE_LINK = "https://qtconsole.readthedocs.io/en/stable/index.html" +BUTTON_TEXT = "" +if sys.platform != "darwin": + BUTTON_TEXT = ("Please click on the button below to run some simple " + "code in this console. This will be useful to show " + "you other important features.") + +# This test should serve as example of keys to use in the tour frame dicts +TEST_TOUR = [ + { + 'title': "Welcome to Spyder introduction tour", + 'content': "Spyder is an interactive development " + "environment. This tip panel supports rich text.
    " + "
    it also supports image insertion to the right so far", + 'image': 'spyder_about', + }, + { + 'title': "Widget display", + 'content': ("This shows how a widget is displayed. The tip panel " + "is adjusted based on the first widget in the list"), + 'widgets': ['button1'], + 'decoration': ['button2'], + 'interact': True, + }, + { + 'title': "Widget display", + 'content': ("This shows how a widget is displayed. The tip panel " + "is adjusted based on the first widget in the list"), + 'widgets': ['button1'], + 'decoration': ['button1'], + 'interact': True, + }, + { + 'title': "Widget display", + 'content': ("This shows how a widget is displayed. The tip panel " + "is adjusted based on the first widget in the list"), + 'widgets': ['button1'], + 'interact': True, + }, + { + 'title': "Widget display and highlight", + 'content': "This shows how a highlighted widget looks", + 'widgets': ['button'], + 'decoration': ['button'], + 'interact': False, + }, +] + + +INTRO_TOUR = [ + { + 'title': _("Welcome to the introduction tour!"), + 'content': _("Spyder is a powerful Interactive " + "Development Environment (or IDE) for the Python " + "programming language.

    " + "Here, we are going to guide you through its most " + "important features.

    " + "Please use the arrow keys or click on the buttons " + "below to move along the tour."), + 'image': 'spyder_about', + }, + { + 'title': _("Editor"), + 'content': _("This is where you write Python code before " + "evaluating it. You can get automatic " + "completions while typing, along with calltips " + "when calling a function and help when hovering " + "over an object." + "

    The Editor comes " + "with a line number area (highlighted here in red) " + "where Spyder shows warnings and syntax errors. " + "They can help you to detect potential problems " + "before running your code.

    " + "You can also set debug breakpoints in the line " + "number area by clicking next to " + "any non-empty line."), + 'widgets': [sw.editor], + 'decoration': [sw.editor_line_number_area], + }, + { + 'title': _("IPython Console"), + 'content': _("This is where you can run Python code, either " + "from the Editor or interactively. To run the " + "current file, press F5 by default, " + "or press F9 to execute the current " + "line or selection.

    " + "The IPython Console comes with many " + "useful features that greatly improve your " + "programming workflow, like syntax highlighting, " + "autocompletion, plotting and 'magic' commands. " + "To learn more, check out the " + "documentation." + "

    {1}").format(QTCONSOLE_LINK, BUTTON_TEXT), + 'widgets': [sw.ipython_console], + 'run': [ + "test_list_tour = [1, 2, 3, 4, 5]", + "test_dict_tour = {'a': 1, 'b': 2}", + ], + }, + { + 'title': _("Variable Explorer"), + 'content': _("In this pane you can view and edit the variables " + "generated during the execution of a program, or " + "those entered directly in the " + "IPython Console.

    " + "If you ran the code in the previous step, " + "the Variable Explorer will show " + "the list and dictionary objects it generated. " + "By double-clicking any variable, " + "a new window will be opened where you " + "can inspect and modify their contents."), + 'widgets': [sw.variable_explorer], + 'interact': True, + }, + { + 'title': _("Help"), + 'content': _("This pane displays documentation of the " + "functions, classes, methods or modules you are " + "currently using in the Editor or the " + "IPython Console." + "

    To use it, press Ctrl+I " + "(Cmd-I on macOS) with the text cursor " + "in or next to the object you want help on."), + 'widgets': [sw.help_plugin], + 'interact': True, + }, + { + 'title': _("Plots"), + 'content': _("This pane shows the figures and images created " + "during your code execution. It allows you to browse, " + "zoom, copy, and save the generated plots."), + 'widgets': [sw.plots_plugin], + 'interact': True, + }, + { + 'title': _("Files"), + 'content': _("This pane lets you browse the files and " + "directories on your computer.

    " + "You can open any file in its " + "corresponding application by double-clicking it, " + "and supported file types will be opened right " + "inside of Spyder.

    " + "The Files pane also allows you to copy one or " + "many absolute or relative paths, automatically " + "formatted as Python strings or lists, and perform " + "a variety of other file operations."), + 'widgets': [sw.file_explorer], + 'interact': True, + }, + { + 'title': _("History Log"), + 'content': _("This pane records all the commands and code run " + "in any IPython console, allowing you to easily " + "retrace your steps for reproducible research."), + 'widgets': [sw.history_log], + 'interact': True, + }, + { + 'title': _("Find"), + 'content': _("The Find pane allows you to search for text in a " + "given directory and navigate through all the found " + "occurrences."), + 'widgets': [sw.find_plugin], + 'interact': True, + }, + { + 'title': _("Profiler"), + 'content': _("The Profiler helps you optimize your code by " + "determining the run time and number of calls for " + "every function and method used in a file. It also " + "allows you to save and compare your results between " + "runs."), + 'widgets': [sw.profiler], + 'interact': True, + }, + { + 'title': _("Code Analysis"), + 'content': _("The Code Analysis helps you improve the quality of " + "your programs by detecting style issues, bad practices " + "and potential bugs."), + 'widgets': [sw.code_analysis], + 'interact': True + }, + { + 'title': _("The end"), + 'content': _('You have reached the end of our tour and are ' + 'ready to start using Spyder! For more ' + 'information, check out our ' + 'documentation.' + '

    ').format(__docs_url__), + 'image': 'spyder_about' + }, +] + + +FEAT30 = [ + { + 'title': _("New features in Spyder 3.0"), + 'content': _("Spyder is an interactive development " + "environment based on bla"), + 'image': 'spyder_about', + }, + { + 'title': _("Welcome to Spyder introduction tour"), + 'content': _("Spyder is an interactive development environment " + "based on bla"), + 'widgets': ['variableexplorer'], + }, +] + + +class TourIdentifiers: + IntroductionTour = "introduction_tour" + TestTour = "test_tour" diff --git a/spyder/plugins/tours/widgets.py b/spyder/plugins/tours/widgets.py index eb23ffcaadf..dfb82d6e898 100644 --- a/spyder/plugins/tours/widgets.py +++ b/spyder/plugins/tours/widgets.py @@ -1,1286 +1,1286 @@ -# -*- coding: utf-8 -*- -# -# Copyright © Spyder Project Contributors -# Licensed under the terms of the MIT License -# (see spyder/__init__.py for details) - -"""Spyder interactive tours""" - -# pylint: disable=C0103 -# pylint: disable=R0903 -# pylint: disable=R0911 -# pylint: disable=R0201 - -# Standard library imports -from math import ceil -import sys - -# Third party imports -from qtpy.QtCore import (QEasingCurve, QPoint, QPropertyAnimation, QRectF, Qt, - Signal) -from qtpy.QtGui import (QBrush, QColor, QIcon, QPainter, QPainterPath, QPen, - QPixmap, QRegion) -from qtpy.QtWidgets import (QAction, QApplication, QComboBox, QDialog, - QGraphicsOpacityEffect, QHBoxLayout, QLabel, - QLayout, QMainWindow, QMenu, QMessageBox, - QPushButton, QSpacerItem, QToolButton, QVBoxLayout, - QWidget) - -# Local imports -from spyder import __docs_url__ -from spyder.api.panel import Panel -from spyder.api.translations import get_translation -from spyder.config.base import _ -from spyder.plugins.layout.layouts import DefaultLayouts -from spyder.py3compat import to_binary_string -from spyder.utils.icon_manager import ima -from spyder.utils.image_path_manager import get_image_path -from spyder.utils.palette import QStylePalette, SpyderPalette -from spyder.utils.qthelpers import add_actions, create_action -from spyder.utils.stylesheet import DialogStyle - -MAIN_TOP_COLOR = MAIN_BG_COLOR = QColor(QStylePalette.COLOR_BACKGROUND_1) - -# Localization -_ = get_translation('spyder') - -MAC = sys.platform == 'darwin' - - -class FadingDialog(QDialog): - """A general fade in/fade out QDialog with some builtin functions""" - sig_key_pressed = Signal() - - def __init__(self, parent, opacity, duration, easing_curve): - super(FadingDialog, self).__init__(parent) - - self.parent = parent - self.opacity_min = min(opacity) - self.opacity_max = max(opacity) - self.duration_fadein = duration[0] - self.duration_fadeout = duration[-1] - self.easing_curve_in = easing_curve[0] - self.easing_curve_out = easing_curve[-1] - self.effect = None - self.anim = None - - self._fade_running = False - self._funcs_before_fade_in = [] - self._funcs_after_fade_in = [] - self._funcs_before_fade_out = [] - self._funcs_after_fade_out = [] - - self.setModal(False) - - def _run(self, funcs): - for func in funcs: - func() - - def _run_before_fade_in(self): - self._run(self._funcs_before_fade_in) - - def _run_after_fade_in(self): - self._run(self._funcs_after_fade_in) - - def _run_before_fade_out(self): - self._run(self._funcs_before_fade_out) - - def _run_after_fade_out(self): - self._run(self._funcs_after_fade_out) - - def _set_fade_finished(self): - self._fade_running = False - - def _fade_setup(self): - self._fade_running = True - self.effect = QGraphicsOpacityEffect(self) - self.setGraphicsEffect(self.effect) - self.anim = QPropertyAnimation( - self.effect, to_binary_string("opacity")) - - # --- public api - def fade_in(self, on_finished_connect): - self._run_before_fade_in() - self._fade_setup() - self.show() - self.raise_() - self.anim.setEasingCurve(self.easing_curve_in) - self.anim.setStartValue(self.opacity_min) - self.anim.setEndValue(self.opacity_max) - self.anim.setDuration(self.duration_fadein) - self.anim.finished.connect(on_finished_connect) - self.anim.finished.connect(self._set_fade_finished) - self.anim.finished.connect(self._run_after_fade_in) - self.anim.start() - - def fade_out(self, on_finished_connect): - self._run_before_fade_out() - self._fade_setup() - self.anim.setEasingCurve(self.easing_curve_out) - self.anim.setStartValue(self.opacity_max) - self.anim.setEndValue(self.opacity_min) - self.anim.setDuration(self.duration_fadeout) - self.anim.finished.connect(on_finished_connect) - self.anim.finished.connect(self._set_fade_finished) - self.anim.finished.connect(self._run_after_fade_out) - self.anim.start() - - def is_fade_running(self): - return self._fade_running - - def set_funcs_before_fade_in(self, funcs): - self._funcs_before_fade_in = funcs - - def set_funcs_after_fade_in(self, funcs): - self._funcs_after_fade_in = funcs - - def set_funcs_before_fade_out(self, funcs): - self._funcs_before_fade_out = funcs - - def set_funcs_after_fade_out(self, funcs): - self._funcs_after_fade_out = funcs - - -class FadingCanvas(FadingDialog): - """The black semi transparent canvas that covers the application""" - def __init__(self, parent, opacity, duration, easing_curve, color, - tour=None): - """Create a black semi transparent canvas that covers the app.""" - super(FadingCanvas, self).__init__(parent, opacity, duration, - easing_curve) - self.parent = parent - self.tour = tour - - # Canvas color - self.color = color - # Decoration color - self.color_decoration = QColor(SpyderPalette.COLOR_ERROR_2) - # Width in pixels for decoration - self.stroke_decoration = 2 - - self.region_mask = None - self.region_subtract = None - self.region_decoration = None - - self.widgets = None # The widget to uncover - self.decoration = None # The widget to draw decoration - self.interaction_on = False - - self.path_current = None - self.path_subtract = None - self.path_full = None - self.path_decoration = None - - # widget setup - self.setWindowFlags(Qt.Dialog | Qt.FramelessWindowHint) - self.setAttribute(Qt.WA_TranslucentBackground) - self.setAttribute(Qt.WA_TransparentForMouseEvents) - self.setModal(False) - self.setFocusPolicy(Qt.NoFocus) - - self.set_funcs_before_fade_in([self.update_canvas]) - self.set_funcs_after_fade_out([lambda: self.update_widgets(None), - lambda: self.update_decoration(None)]) - - def set_interaction(self, value): - self.interaction_on = value - - def update_canvas(self): - w, h = self.parent.size().width(), self.parent.size().height() - - self.path_full = QPainterPath() - self.path_subtract = QPainterPath() - self.path_decoration = QPainterPath() - self.region_mask = QRegion(0, 0, w, h) - - self.path_full.addRect(0, 0, w, h) - # Add the path - if self.widgets is not None: - for widget in self.widgets: - temp_path = QPainterPath() - # if widget is not found... find more general way to handle - if widget is not None: - widget.raise_() - widget.show() - geo = widget.frameGeometry() - width, height = geo.width(), geo.height() - point = widget.mapTo(self.parent, QPoint(0, 0)) - x, y = point.x(), point.y() - - temp_path.addRect(QRectF(x, y, width, height)) - - temp_region = QRegion(x, y, width, height) - - if self.interaction_on: - self.region_mask = self.region_mask.subtracted(temp_region) - self.path_subtract = self.path_subtract.united(temp_path) - - self.path_current = self.path_full.subtracted(self.path_subtract) - else: - self.path_current = self.path_full - if self.decoration is not None: - for widgets in self.decoration: - if isinstance(widgets, QWidget): - widgets = [widgets] - geoms = [] - for widget in widgets: - widget.raise_() - widget.show() - geo = widget.frameGeometry() - width, height = geo.width(), geo.height() - point = widget.mapTo(self.parent, QPoint(0, 0)) - x, y = point.x(), point.y() - geoms.append((x, y, width, height)) - x = min([geom[0] for geom in geoms]) - y = min([geom[1] for geom in geoms]) - width = max([ - geom[0] + geom[2] for geom in geoms]) - x - height = max([ - geom[1] + geom[3] for geom in geoms]) - y - temp_path = QPainterPath() - temp_path.addRect(QRectF(x, y, width, height)) - - temp_region_1 = QRegion(x-1, y-1, width+2, height+2) - temp_region_2 = QRegion(x+1, y+1, width-2, height-2) - temp_region = temp_region_1.subtracted(temp_region_2) - - if self.interaction_on: - self.region_mask = self.region_mask.united(temp_region) - - self.path_decoration = self.path_decoration.united(temp_path) - else: - self.path_decoration.addRect(0, 0, 0, 0) - - # Add a decoration stroke around widget - self.setMask(self.region_mask) - self.update() - self.repaint() - - def update_widgets(self, widgets): - self.widgets = widgets - - def update_decoration(self, widgets): - self.decoration = widgets - - def paintEvent(self, event): - """Override Qt method""" - painter = QPainter(self) - painter.setRenderHint(QPainter.Antialiasing) - # Decoration - painter.fillPath(self.path_current, QBrush(self.color)) - painter.strokePath(self.path_decoration, QPen(self.color_decoration, - self.stroke_decoration)) -# decoration_fill = QColor(self.color_decoration) -# decoration_fill.setAlphaF(0.25) -# painter.fillPath(self.path_decoration, decoration_fill) - - def reject(self): - """Override Qt method""" - if not self.is_fade_running(): - key = Qt.Key_Escape - self.key_pressed = key - self.sig_key_pressed.emit() - - def mousePressEvent(self, event): - """Override Qt method""" - pass - - def focusInEvent(self, event): - """Override Qt method.""" - # To be used so tips do not appear outside spyder - if self.hasFocus(): - self.tour.gain_focus() - - def focusOutEvent(self, event): - """Override Qt method.""" - # To be used so tips do not appear outside spyder - if self.tour.step_current != 0: - self.tour.lost_focus() - - -class FadingTipBox(FadingDialog): - """Dialog that contains the text for each frame in the tour.""" - def __init__(self, parent, opacity, duration, easing_curve, tour=None, - color_top=None, color_back=None, combobox_background=None): - super(FadingTipBox, self).__init__(parent, opacity, duration, - easing_curve) - self.holder = self.anim # needed for qt to work - self.parent = parent - self.tour = tour - - self.frames = None - self.offset_shadow = 0 - self.fixed_width = 300 - - self.key_pressed = None - - self.setAttribute(Qt.WA_TranslucentBackground) - self.setWindowFlags(Qt.Dialog | Qt.FramelessWindowHint | - Qt.WindowStaysOnTopHint) - self.setModal(False) - - # Widgets - def toolbutton(icon): - bt = QToolButton() - bt.setAutoRaise(True) - bt.setIcon(icon) - return bt - - self.button_close = toolbutton(ima.icon("tour.close")) - self.button_home = toolbutton(ima.icon("tour.home")) - self.button_previous = toolbutton(ima.icon("tour.previous")) - self.button_end = toolbutton(ima.icon("tour.end")) - self.button_next = toolbutton(ima.icon("tour.next")) - self.button_run = QPushButton(_('Run code')) - self.button_disable = None - self.button_current = QToolButton() - self.label_image = QLabel() - - self.label_title = QLabel() - self.combo_title = QComboBox() - self.label_current = QLabel() - self.label_content = QLabel() - - self.label_content.setOpenExternalLinks(True) - self.label_content.setMinimumWidth(self.fixed_width) - self.label_content.setMaximumWidth(self.fixed_width) - - self.label_current.setAlignment(Qt.AlignCenter) - - self.label_content.setWordWrap(True) - - self.widgets = [self.label_content, self.label_title, - self.label_current, self.combo_title, - self.button_close, self.button_run, self.button_next, - self.button_previous, self.button_end, - self.button_home, self.button_current] - - arrow = get_image_path('hide') - - self.color_top = color_top - self.color_back = color_back - self.combobox_background = combobox_background - self.stylesheet = '''QComboBox {{ - padding-left: 5px; - background-color: {} - border-width: 0px; - border-radius: 0px; - min-height:20px; - max-height:20px; - }} - - QComboBox::drop-down {{ - subcontrol-origin: padding; - subcontrol-position: top left; - border-width: 0px; - }} - - QComboBox::down-arrow {{ - image: url({}); - }} - '''.format(self.combobox_background.name(), arrow) - # Windows fix, slashes should be always in unix-style - self.stylesheet = self.stylesheet.replace('\\', '/') - - self.setFocusPolicy(Qt.StrongFocus) - for widget in self.widgets: - widget.setFocusPolicy(Qt.NoFocus) - widget.setStyleSheet(self.stylesheet) - - layout_top = QHBoxLayout() - layout_top.addWidget(self.combo_title) - layout_top.addStretch() - layout_top.addWidget(self.button_close) - layout_top.addSpacerItem(QSpacerItem(self.offset_shadow, - self.offset_shadow)) - - layout_content = QHBoxLayout() - layout_content.addWidget(self.label_content) - layout_content.addWidget(self.label_image) - layout_content.addSpacerItem(QSpacerItem(5, 5)) - - layout_run = QHBoxLayout() - layout_run.addStretch() - layout_run.addWidget(self.button_run) - layout_run.addStretch() - layout_run.addSpacerItem(QSpacerItem(self.offset_shadow, - self.offset_shadow)) - - layout_navigation = QHBoxLayout() - layout_navigation.addWidget(self.button_home) - layout_navigation.addWidget(self.button_previous) - layout_navigation.addStretch() - layout_navigation.addWidget(self.label_current) - layout_navigation.addStretch() - layout_navigation.addWidget(self.button_next) - layout_navigation.addWidget(self.button_end) - layout_navigation.addSpacerItem(QSpacerItem(self.offset_shadow, - self.offset_shadow)) - - layout = QVBoxLayout() - layout.addLayout(layout_top) - layout.addStretch() - layout.addSpacerItem(QSpacerItem(15, 15)) - layout.addLayout(layout_content) - layout.addLayout(layout_run) - layout.addStretch() - layout.addSpacerItem(QSpacerItem(15, 15)) - layout.addLayout(layout_navigation) - layout.addSpacerItem(QSpacerItem(self.offset_shadow, - self.offset_shadow)) - - layout.setSizeConstraint(QLayout.SetFixedSize) - - self.setLayout(layout) - - self.set_funcs_before_fade_in([self._disable_widgets]) - self.set_funcs_after_fade_in([self._enable_widgets, self.setFocus]) - self.set_funcs_before_fade_out([self._disable_widgets]) - - self.setContextMenuPolicy(Qt.CustomContextMenu) - - # signals and slots - # These are defined every time by the AnimatedTour Class - - def _disable_widgets(self): - for widget in self.widgets: - widget.setDisabled(True) - - def _enable_widgets(self): - self.setWindowFlags(Qt.Dialog | Qt.FramelessWindowHint | - Qt.WindowStaysOnTopHint) - for widget in self.widgets: - widget.setDisabled(False) - - if self.button_disable == 'previous': - self.button_previous.setDisabled(True) - self.button_home.setDisabled(True) - elif self.button_disable == 'next': - self.button_next.setDisabled(True) - self.button_end.setDisabled(True) - self.button_run.setDisabled(sys.platform == "darwin") - - def set_data(self, title, content, current, image, run, frames=None, - step=None): - self.label_title.setText(title) - self.combo_title.clear() - self.combo_title.addItems(frames) - self.combo_title.setCurrentIndex(step) -# min_content_len = max([len(f) for f in frames]) -# self.combo_title.setMinimumContentsLength(min_content_len) - - # Fix and try to see how it looks with a combo box - self.label_current.setText(current) - self.button_current.setText(current) - self.label_content.setText(content) - self.image = image - - if image is None: - self.label_image.setFixedHeight(1) - self.label_image.setFixedWidth(1) - else: - extension = image.split('.')[-1] - self.image = QPixmap(get_image_path(image), extension) - self.label_image.setPixmap(self.image) - self.label_image.setFixedSize(self.image.size()) - - if run is None: - self.button_run.setVisible(False) - else: - self.button_run.setVisible(True) - if sys.platform == "darwin": - self.button_run.setToolTip("Not available on macOS") - - # Refresh layout - self.layout().activate() - - def set_pos(self, x, y): - self.x = ceil(x) - self.y = ceil(y) - self.move(QPoint(self.x, self.y)) - - def build_paths(self): - geo = self.geometry() - radius = 0 - shadow = self.offset_shadow - x0, y0 = geo.x(), geo.y() - width, height = geo.width() - shadow, geo.height() - shadow - - left, top = 0, 0 - right, bottom = width, height - - self.round_rect_path = QPainterPath() - self.round_rect_path.moveTo(right, top + radius) - self.round_rect_path.arcTo(right-radius, top, radius, radius, 0.0, - 90.0) - self.round_rect_path.lineTo(left+radius, top) - self.round_rect_path.arcTo(left, top, radius, radius, 90.0, 90.0) - self.round_rect_path.lineTo(left, bottom-radius) - self.round_rect_path.arcTo(left, bottom-radius, radius, radius, 180.0, - 90.0) - self.round_rect_path.lineTo(right-radius, bottom) - self.round_rect_path.arcTo(right-radius, bottom-radius, radius, radius, - 270.0, 90.0) - self.round_rect_path.closeSubpath() - - # Top path - header = 36 - offset = 2 - left, top = offset, offset - right = width - (offset) - self.top_rect_path = QPainterPath() - self.top_rect_path.lineTo(right, top + radius) - self.top_rect_path.moveTo(right, top + radius) - self.top_rect_path.arcTo(right-radius, top, radius, radius, 0.0, 90.0) - self.top_rect_path.lineTo(left+radius, top) - self.top_rect_path.arcTo(left, top, radius, radius, 90.0, 90.0) - self.top_rect_path.lineTo(left, top + header) - self.top_rect_path.lineTo(right, top + header) - - def paintEvent(self, event): - """Override Qt method.""" - self.build_paths() - - painter = QPainter(self) - painter.setRenderHint(QPainter.Antialiasing) - - painter.fillPath(self.round_rect_path, self.color_back) - painter.fillPath(self.top_rect_path, self.color_top) - painter.strokePath(self.round_rect_path, QPen(Qt.gray, 1)) - - # TODO: Build the pointing arrow? - - def keyReleaseEvent(self, event): - """Override Qt method.""" - key = event.key() - self.key_pressed = key - - keys = [Qt.Key_Right, Qt.Key_Left, Qt.Key_Down, Qt.Key_Up, - Qt.Key_Escape, Qt.Key_PageUp, Qt.Key_PageDown, - Qt.Key_Home, Qt.Key_End, Qt.Key_Menu] - - if key in keys: - if not self.is_fade_running(): - self.sig_key_pressed.emit() - - def mousePressEvent(self, event): - """Override Qt method.""" - # Raise the main application window on click - self.parent.raise_() - self.raise_() - - if event.button() == Qt.RightButton: - pass -# clicked_widget = self.childAt(event.x(), event.y()) -# if clicked_widget == self.label_current: -# self.context_menu_requested(event) - - def focusOutEvent(self, event): - """Override Qt method.""" - # To be used so tips do not appear outside spyder - self.tour.lost_focus() - - def context_menu_requested(self, event): - pos = QPoint(event.x(), event.y()) - menu = QMenu(self) - - actions = [] - action_title = create_action(self, _('Go to step: '), icon=QIcon()) - action_title.setDisabled(True) - actions.append(action_title) -# actions.append(create_action(self, _(': '), icon=QIcon())) - - add_actions(menu, actions) - - menu.popup(self.mapToGlobal(pos)) - - def reject(self): - """Qt method to handle escape key event""" - if not self.is_fade_running(): - key = Qt.Key_Escape - self.key_pressed = key - self.sig_key_pressed.emit() - - -class AnimatedTour(QWidget): - """Widget to display an interactive tour.""" - - def __init__(self, parent): - QWidget.__init__(self, parent) - - self.parent = parent - - # Variables to adjust - self.duration_canvas = [666, 666] - self.duration_tips = [333, 333] - self.opacity_canvas = [0.0, 0.7] - self.opacity_tips = [0.0, 1.0] - self.color = Qt.black - self.easing_curve = [QEasingCurve.Linear] - - self.current_step = 0 - self.step_current = 0 - self.steps = 0 - self.canvas = None - self.tips = None - self.frames = None - self.spy_window = None - self.initial_fullscreen_state = None - - self.widgets = None - self.dockwidgets = None - self.decoration = None - self.run = None - - self.is_tour_set = False - self.is_running = False - - # Widgets - self.canvas = FadingCanvas(self.parent, self.opacity_canvas, - self.duration_canvas, self.easing_curve, - self.color, tour=self) - self.tips = FadingTipBox(self.parent, self.opacity_tips, - self.duration_tips, self.easing_curve, - tour=self, color_top=MAIN_TOP_COLOR, - color_back=MAIN_BG_COLOR, - combobox_background=MAIN_TOP_COLOR) - - # Widgets setup - # Needed to fix spyder-ide/spyder#2204. - self.setAttribute(Qt.WA_TransparentForMouseEvents) - - # Signals and slots - self.tips.button_next.clicked.connect(self.next_step) - self.tips.button_previous.clicked.connect(self.previous_step) - self.tips.button_close.clicked.connect(self.close_tour) - self.tips.button_run.clicked.connect(self.run_code) - self.tips.button_home.clicked.connect(self.first_step) - self.tips.button_end.clicked.connect(self.last_step) - self.tips.button_run.clicked.connect( - lambda: self.tips.button_run.setDisabled(True)) - self.tips.combo_title.currentIndexChanged.connect(self.go_to_step) - - # Main window move or resize - self.parent.sig_resized.connect(self._resized) - self.parent.sig_moved.connect(self._moved) - - # To capture the arrow keys that allow moving the tour - self.tips.sig_key_pressed.connect(self._key_pressed) - - # To control the focus of tour - self.setting_data = False - self.hidden = False - - def _resized(self, event): - if self.is_running: - geom = self.parent.geometry() - self.canvas.setFixedSize(geom.width(), geom.height()) - self.canvas.update_canvas() - - if self.is_tour_set: - self._set_data() - - def _moved(self, event): - if self.is_running: - geom = self.parent.geometry() - self.canvas.move(geom.x(), geom.y()) - - if self.is_tour_set: - self._set_data() - - def _close_canvas(self): - self.tips.hide() - self.canvas.fade_out(self.canvas.hide) - - def _clear_canvas(self): - # TODO: Add option to also make it white... might be useful? - # Make canvas black before transitions - self.canvas.update_widgets(None) - self.canvas.update_decoration(None) - self.canvas.update_canvas() - - def _move_step(self): - self._set_data() - - # Show/raise the widget so it is located first! - widgets = self.dockwidgets - if widgets is not None: - widget = widgets[0] - if widget is not None: - widget.show() - widget.raise_() - - self._locate_tip_box() - - # Change in canvas only after fadein finishes, for visual aesthetics - self.tips.fade_in(self.canvas.update_canvas) - self.tips.raise_() - - def _set_modal(self, value, widgets): - platform = sys.platform.lower() - - if 'linux' in platform: - pass - elif 'win' in platform: - for widget in widgets: - widget.setModal(value) - widget.hide() - widget.show() - elif 'darwin' in platform: - pass - else: - pass - - def _process_widgets(self, names, spy_window): - widgets = [] - dockwidgets = [] - - for name in names: - try: - base = name.split('.')[0] - try: - temp = getattr(spy_window, name) - except AttributeError: - temp = None - # Check if it is the current editor - if 'get_current_editor()' in name: - temp = temp.get_current_editor() - temp = getattr(temp, name.split('.')[-1]) - if temp is None: - raise - except AttributeError: - temp = eval(f"spy_window.{name}") - - widgets.append(temp) - - # Check if it is a dockwidget and make the widget a dockwidget - # If not return the same widget - temp = getattr(temp, 'dockwidget', temp) - dockwidgets.append(temp) - - return widgets, dockwidgets - - def _set_data(self): - """Set data that is displayed in each step of the tour.""" - self.setting_data = True - step, steps, frames = self.step_current, self.steps, self.frames - current = '{0}/{1}'.format(step + 1, steps) - frame = frames[step] - - combobox_frames = [u"{0}. {1}".format(i+1, f['title']) - for i, f in enumerate(frames)] - - title, content, image = '', '', None - widgets, dockwidgets, decoration = None, None, None - run = None - - # Check if entry exists in dic and act accordingly - if 'title' in frame: - title = frame['title'] - - if 'content' in frame: - content = frame['content'] - - if 'widgets' in frame: - widget_names = frames[step]['widgets'] - # Get the widgets based on their name - widgets, dockwidgets = self._process_widgets(widget_names, - self.spy_window) - self.widgets = widgets - self.dockwidgets = dockwidgets - - if 'decoration' in frame: - widget_names = frames[step]['decoration'] - deco, decoration = self._process_widgets(widget_names, - self.spy_window) - self.decoration = decoration - - if 'image' in frame: - image = frames[step]['image'] - - if 'interact' in frame: - self.canvas.set_interaction(frame['interact']) - if frame['interact']: - self._set_modal(False, [self.tips]) - else: - self._set_modal(True, [self.tips]) - else: - self.canvas.set_interaction(False) - self._set_modal(True, [self.tips]) - - if 'run' in frame: - # Assume that the first widget is the console - run = frame['run'] - self.run = run - - self.tips.set_data(title, content, current, image, run, - frames=combobox_frames, step=step) - self._check_buttons() - - # Make canvas black when starting a new place of decoration - self.canvas.update_widgets(dockwidgets) - self.canvas.update_decoration(decoration) - self.setting_data = False - - def _locate_tip_box(self): - dockwidgets = self.dockwidgets - - # Store the dimensions of the main window - geo = self.parent.frameGeometry() - x, y, width, height = geo.x(), geo.y(), geo.width(), geo.height() - self.width_main = width - self.height_main = height - self.x_main = x - self.y_main = y - - delta = 20 - offset = 10 - - # Here is the tricky part to define the best position for the - # tip widget - if dockwidgets is not None: - if dockwidgets[0] is not None: - geo = dockwidgets[0].geometry() - x, y, width, height = (geo.x(), geo.y(), - geo.width(), geo.height()) - - point = dockwidgets[0].mapToGlobal(QPoint(0, 0)) - x_glob, y_glob = point.x(), point.y() - - # Put tip to the opposite side of the pane - if x < self.tips.width(): - x = x_glob + width + delta - y = y_glob + height/2 - self.tips.height()/2 - else: - x = x_glob - self.tips.width() - delta - y = y_glob + height/2 - self.tips.height()/2 - - if (y + self.tips.height()) > (self.y_main + self.height_main): - y = ( - y - - (y + self.tips.height() - ( - self.y_main + self.height_main)) - offset - ) - else: - # Center on parent - x = self.x_main + self.width_main/2 - self.tips.width()/2 - y = self.y_main + self.height_main/2 - self.tips.height()/2 - - self.tips.set_pos(x, y) - - def _check_buttons(self): - step, steps = self.step_current, self.steps - self.tips.button_disable = None - - if step == 0: - self.tips.button_disable = 'previous' - - if step == steps - 1: - self.tips.button_disable = 'next' - - def _key_pressed(self): - key = self.tips.key_pressed - - if ((key == Qt.Key_Right or key == Qt.Key_Down or - key == Qt.Key_PageDown) and self.step_current != self.steps - 1): - self.next_step() - elif ((key == Qt.Key_Left or key == Qt.Key_Up or - key == Qt.Key_PageUp) and self.step_current != 0): - self.previous_step() - elif key == Qt.Key_Escape: - self.close_tour() - elif key == Qt.Key_Home and self.step_current != 0: - self.first_step() - elif key == Qt.Key_End and self.step_current != self.steps - 1: - self.last_step() - elif key == Qt.Key_Menu: - pos = self.tips.label_current.pos() - self.tips.context_menu_requested(pos) - - def _hiding(self): - self.hidden = True - self.tips.hide() - - # --- public api - def run_code(self): - codelines = self.run - console = self.widgets[0] - for codeline in codelines: - console.execute_code(codeline) - - def set_tour(self, index, frames, spy_window): - self.spy_window = spy_window - self.active_tour_index = index - self.last_frame_active = frames['last'] - self.frames = frames['tour'] - self.steps = len(self.frames) - - self.is_tour_set = True - - def _handle_fullscreen(self): - if (self.spy_window.isFullScreen() or - self.spy_window.layouts._fullscreen_flag): - if sys.platform == 'darwin': - self.spy_window.setUpdatesEnabled(True) - msg_title = _("Request") - msg = _("To run the tour, please press the green button on " - "the left of the Spyder window's title bar to take " - "it out of fullscreen mode.") - QMessageBox.information(self, msg_title, msg, - QMessageBox.Ok) - return True - if self.spy_window.layouts._fullscreen_flag: - self.spy_window.layouts.toggle_fullscreen() - else: - self.spy_window.setWindowState( - self.spy_window.windowState() - & (~ Qt.WindowFullScreen)) - return False - - def start_tour(self): - self.spy_window.setUpdatesEnabled(False) - if self._handle_fullscreen(): - return - self.spy_window.layouts.save_current_window_settings( - 'layout_current_temp/', - section="quick_layouts", - ) - self.spy_window.layouts.quick_layout_switch( - DefaultLayouts.SpyderLayout) - geo = self.parent.geometry() - x, y, width, height = geo.x(), geo.y(), geo.width(), geo.height() -# self.parent_x = x -# self.parent_y = y -# self.parent_w = width -# self.parent_h = height - - # FIXME: reset step to last used value - # Reset step to beginning - self.step_current = self.last_frame_active - - # Adjust the canvas size to match the main window size - self.canvas.setFixedSize(width, height) - self.canvas.move(QPoint(x, y)) - self.spy_window.setUpdatesEnabled(True) - self.canvas.fade_in(self._move_step) - self._clear_canvas() - - self.is_running = True - - def close_tour(self): - self.tips.fade_out(self._close_canvas) - self.spy_window.setUpdatesEnabled(False) - self.canvas.set_interaction(False) - self._set_modal(True, [self.tips]) - self.canvas.hide() - - try: - # set the last played frame by updating the available tours in - # parent. This info will be lost on restart. - self.parent.tours_available[self.active_tour_index]['last'] =\ - self.step_current - except Exception: - pass - - self.is_running = False - self.spy_window.layouts.quick_layout_switch('current_temp') - self.spy_window.setUpdatesEnabled(True) - - def hide_tips(self): - """Hide tips dialog when the main window loses focus.""" - self._clear_canvas() - self.tips.fade_out(self._hiding) - - def unhide_tips(self): - """Unhide tips dialog when the main window loses focus.""" - self._clear_canvas() - self._move_step() - self.hidden = False - - def next_step(self): - self._clear_canvas() - self.step_current += 1 - self.tips.fade_out(self._move_step) - - def previous_step(self): - self._clear_canvas() - self.step_current -= 1 - self.tips.fade_out(self._move_step) - - def go_to_step(self, number, id_=None): - self._clear_canvas() - self.step_current = number - self.tips.fade_out(self._move_step) - - def last_step(self): - self.go_to_step(self.steps - 1) - - def first_step(self): - self.go_to_step(0) - - def lost_focus(self): - """Confirm if the tour loses focus and hides the tips.""" - if (self.is_running and - not self.setting_data and not self.hidden): - if sys.platform == 'darwin': - if not self.tour_has_focus(): - self.hide_tips() - if not self.any_has_focus(): - self.close_tour() - else: - if not self.any_has_focus(): - self.hide_tips() - - def gain_focus(self): - """Confirm if the tour regains focus and unhides the tips.""" - if (self.is_running and self.any_has_focus() and - not self.setting_data and self.hidden): - self.unhide_tips() - - def any_has_focus(self): - """Returns True if tour or main window has focus.""" - f = (self.hasFocus() or self.parent.hasFocus() or - self.tour_has_focus() or self.isActiveWindow()) - return f - - def tour_has_focus(self): - """Returns true if tour or any of its components has focus.""" - f = (self.tips.hasFocus() or self.canvas.hasFocus() or - self.tips.isActiveWindow()) - return f - - -class OpenTourDialog(QDialog): - """Initial widget with tour.""" - - def __init__(self, parent, tour_function): - super().__init__(parent) - if MAC: - flags = (self.windowFlags() | Qt.WindowStaysOnTopHint - & ~Qt.WindowContextHelpButtonHint) - else: - flags = self.windowFlags() & ~Qt.WindowContextHelpButtonHint - self.setWindowFlags(flags) - self.tour_function = tour_function - - # Image - images_layout = QHBoxLayout() - icon_filename = 'tour-spyder-logo' - image_path = get_image_path(icon_filename) - image = QPixmap(image_path) - image_label = QLabel() - image_height = int(image.height() * DialogStyle.IconScaleFactor) - image_width = int(image.width() * DialogStyle.IconScaleFactor) - image = image.scaled(image_width, image_height, Qt.KeepAspectRatio, - Qt.SmoothTransformation) - image_label.setPixmap(image) - - images_layout.addStretch() - images_layout.addWidget(image_label) - images_layout.addStretch() - if MAC: - images_layout.setContentsMargins(0, -5, 20, 0) - else: - images_layout.setContentsMargins(0, -8, 35, 0) - - # Label - tour_label_title = QLabel(_("Welcome to Spyder!")) - tour_label_title.setStyleSheet(f"font-size: {DialogStyle.TitleFontSize}") - tour_label_title.setWordWrap(True) - tour_label = QLabel( - _("Check out our interactive tour to " - "explore some of Spyder's panes and features.")) - tour_label.setStyleSheet(f"font-size: {DialogStyle.ContentFontSize}") - tour_label.setWordWrap(True) - tour_label.setFixedWidth(340) - - # Buttons - buttons_layout = QHBoxLayout() - dialog_tour_color = QStylePalette.COLOR_BACKGROUND_2 - start_tour_color = QStylePalette.COLOR_ACCENT_2 - start_tour_hover = QStylePalette.COLOR_ACCENT_3 - start_tour_pressed = QStylePalette.COLOR_ACCENT_4 - dismiss_tour_color = QStylePalette.COLOR_BACKGROUND_4 - dismiss_tour_hover = QStylePalette.COLOR_BACKGROUND_5 - dismiss_tour_pressed = QStylePalette.COLOR_BACKGROUND_6 - font_color = QStylePalette.COLOR_TEXT_1 - self.launch_tour_button = QPushButton(_('Start tour')) - self.launch_tour_button.setStyleSheet(( - "QPushButton {{ " - "background-color: {background_color};" - "border-color: {border_color};" - "font-size: {font_size};" - "color: {font_color};" - "padding: {padding}}}" - "QPushButton:hover:!pressed {{ " - "background-color: {color_hover}}}" - "QPushButton:pressed {{ " - "background-color: {color_pressed}}}" - ).format(background_color=start_tour_color, - border_color=start_tour_color, - font_size=DialogStyle.ButtonsFontSize, - font_color=font_color, - padding=DialogStyle.ButtonsPadding, - color_hover=start_tour_hover, - color_pressed=start_tour_pressed)) - self.launch_tour_button.setAutoDefault(False) - self.dismiss_button = QPushButton(_('Dismiss')) - self.dismiss_button.setStyleSheet(( - "QPushButton {{ " - "background-color: {background_color};" - "border-color: {border_color};" - "font-size: {font_size};" - "color: {font_color};" - "padding: {padding}}}" - "QPushButton:hover:!pressed {{ " - "background-color: {color_hover}}}" - "QPushButton:pressed {{ " - "background-color: {color_pressed}}}" - ).format(background_color=dismiss_tour_color, - border_color=dismiss_tour_color, - font_size=DialogStyle.ButtonsFontSize, - font_color=font_color, - padding=DialogStyle.ButtonsPadding, - color_hover=dismiss_tour_hover, - color_pressed=dismiss_tour_pressed)) - self.dismiss_button.setAutoDefault(False) - - buttons_layout.addStretch() - buttons_layout.addWidget(self.launch_tour_button) - if not MAC: - buttons_layout.addSpacing(10) - buttons_layout.addWidget(self.dismiss_button) - - layout = QHBoxLayout() - layout.addLayout(images_layout) - - label_layout = QVBoxLayout() - label_layout.addWidget(tour_label_title) - if not MAC: - label_layout.addSpacing(3) - label_layout.addWidget(tour_label) - else: - label_layout.addWidget(tour_label) - label_layout.addSpacing(10) - - vertical_layout = QVBoxLayout() - if not MAC: - vertical_layout.addStretch() - vertical_layout.addLayout(label_layout) - vertical_layout.addSpacing(20) - vertical_layout.addLayout(buttons_layout) - vertical_layout.addStretch() - else: - vertical_layout.addLayout(label_layout) - vertical_layout.addLayout(buttons_layout) - - general_layout = QHBoxLayout() - if not MAC: - general_layout.addStretch() - general_layout.addLayout(layout) - general_layout.addSpacing(1) - general_layout.addLayout(vertical_layout) - general_layout.addStretch() - else: - general_layout.addLayout(layout) - general_layout.addLayout(vertical_layout) - - self.setLayout(general_layout) - - self.launch_tour_button.clicked.connect(self._start_tour) - self.dismiss_button.clicked.connect(self.close) - self.setStyleSheet(f"background-color:{dialog_tour_color}") - self.setContentsMargins(18, 40, 18, 40) - if not MAC: - self.setFixedSize(640, 280) - - def _start_tour(self): - self.close() - self.tour_function() - - -# ---------------------------------------------------------------------------- -# Used for testing the functionality -# ---------------------------------------------------------------------------- - -class TourTestWindow(QMainWindow): - """ """ - sig_resized = Signal("QResizeEvent") - sig_moved = Signal("QMoveEvent") - - def __init__(self): - super(TourTestWindow, self).__init__() - self.setGeometry(300, 100, 400, 600) - self.setWindowTitle('Exploring QMainWindow') - - self.exit = QAction('Exit', self) - self.exit.setStatusTip('Exit program') - - # create the menu bar - menubar = self.menuBar() - file_ = menubar.addMenu('&File') - file_.addAction(self.exit) - - # create the status bar - self.statusBar() - - # QWidget or its instance needed for box layout - self.widget = QWidget(self) - - self.button = QPushButton('test') - self.button1 = QPushButton('1') - self.button2 = QPushButton('2') - - effect = QGraphicsOpacityEffect(self.button2) - self.button2.setGraphicsEffect(effect) - self.anim = QPropertyAnimation(effect, to_binary_string("opacity")) - self.anim.setStartValue(0.01) - self.anim.setEndValue(1.0) - self.anim.setDuration(500) - - lay = QVBoxLayout() - lay.addWidget(self.button) - lay.addStretch() - lay.addWidget(self.button1) - lay.addWidget(self.button2) - - self.widget.setLayout(lay) - - self.setCentralWidget(self.widget) - self.button.clicked.connect(self.action1) - self.button1.clicked.connect(self.action2) - - self.tour = AnimatedTour(self) - - def action1(self): - frames = get_tour('test') - index = 0 - dic = {'last': 0, 'tour': frames} - self.tour.set_tour(index, dic, self) - self.tour.start_tour() - - def action2(self): - self.anim.start() - - def resizeEvent(self, event): - """Reimplement Qt method""" - QMainWindow.resizeEvent(self, event) - self.sig_resized.emit(event) - - def moveEvent(self, event): - """Reimplement Qt method""" - QMainWindow.moveEvent(self, event) - self.sig_moved.emit(event) - - -def local_test(): - from spyder.utils.qthelpers import qapplication - - app = QApplication([]) - win = TourTestWindow() - win.show() - app.exec_() - - -if __name__ == '__main__': - local_test() +# -*- coding: utf-8 -*- +# +# Copyright © Spyder Project Contributors +# Licensed under the terms of the MIT License +# (see spyder/__init__.py for details) + +"""Spyder interactive tours""" + +# pylint: disable=C0103 +# pylint: disable=R0903 +# pylint: disable=R0911 +# pylint: disable=R0201 + +# Standard library imports +from math import ceil +import sys + +# Third party imports +from qtpy.QtCore import (QEasingCurve, QPoint, QPropertyAnimation, QRectF, Qt, + Signal) +from qtpy.QtGui import (QBrush, QColor, QIcon, QPainter, QPainterPath, QPen, + QPixmap, QRegion) +from qtpy.QtWidgets import (QAction, QApplication, QComboBox, QDialog, + QGraphicsOpacityEffect, QHBoxLayout, QLabel, + QLayout, QMainWindow, QMenu, QMessageBox, + QPushButton, QSpacerItem, QToolButton, QVBoxLayout, + QWidget) + +# Local imports +from spyder import __docs_url__ +from spyder.api.panel import Panel +from spyder.api.translations import get_translation +from spyder.config.base import _ +from spyder.plugins.layout.layouts import DefaultLayouts +from spyder.py3compat import to_binary_string +from spyder.utils.icon_manager import ima +from spyder.utils.image_path_manager import get_image_path +from spyder.utils.palette import QStylePalette, SpyderPalette +from spyder.utils.qthelpers import add_actions, create_action +from spyder.utils.stylesheet import DialogStyle + +MAIN_TOP_COLOR = MAIN_BG_COLOR = QColor(QStylePalette.COLOR_BACKGROUND_1) + +# Localization +_ = get_translation('spyder') + +MAC = sys.platform == 'darwin' + + +class FadingDialog(QDialog): + """A general fade in/fade out QDialog with some builtin functions""" + sig_key_pressed = Signal() + + def __init__(self, parent, opacity, duration, easing_curve): + super(FadingDialog, self).__init__(parent) + + self.parent = parent + self.opacity_min = min(opacity) + self.opacity_max = max(opacity) + self.duration_fadein = duration[0] + self.duration_fadeout = duration[-1] + self.easing_curve_in = easing_curve[0] + self.easing_curve_out = easing_curve[-1] + self.effect = None + self.anim = None + + self._fade_running = False + self._funcs_before_fade_in = [] + self._funcs_after_fade_in = [] + self._funcs_before_fade_out = [] + self._funcs_after_fade_out = [] + + self.setModal(False) + + def _run(self, funcs): + for func in funcs: + func() + + def _run_before_fade_in(self): + self._run(self._funcs_before_fade_in) + + def _run_after_fade_in(self): + self._run(self._funcs_after_fade_in) + + def _run_before_fade_out(self): + self._run(self._funcs_before_fade_out) + + def _run_after_fade_out(self): + self._run(self._funcs_after_fade_out) + + def _set_fade_finished(self): + self._fade_running = False + + def _fade_setup(self): + self._fade_running = True + self.effect = QGraphicsOpacityEffect(self) + self.setGraphicsEffect(self.effect) + self.anim = QPropertyAnimation( + self.effect, to_binary_string("opacity")) + + # --- public api + def fade_in(self, on_finished_connect): + self._run_before_fade_in() + self._fade_setup() + self.show() + self.raise_() + self.anim.setEasingCurve(self.easing_curve_in) + self.anim.setStartValue(self.opacity_min) + self.anim.setEndValue(self.opacity_max) + self.anim.setDuration(self.duration_fadein) + self.anim.finished.connect(on_finished_connect) + self.anim.finished.connect(self._set_fade_finished) + self.anim.finished.connect(self._run_after_fade_in) + self.anim.start() + + def fade_out(self, on_finished_connect): + self._run_before_fade_out() + self._fade_setup() + self.anim.setEasingCurve(self.easing_curve_out) + self.anim.setStartValue(self.opacity_max) + self.anim.setEndValue(self.opacity_min) + self.anim.setDuration(self.duration_fadeout) + self.anim.finished.connect(on_finished_connect) + self.anim.finished.connect(self._set_fade_finished) + self.anim.finished.connect(self._run_after_fade_out) + self.anim.start() + + def is_fade_running(self): + return self._fade_running + + def set_funcs_before_fade_in(self, funcs): + self._funcs_before_fade_in = funcs + + def set_funcs_after_fade_in(self, funcs): + self._funcs_after_fade_in = funcs + + def set_funcs_before_fade_out(self, funcs): + self._funcs_before_fade_out = funcs + + def set_funcs_after_fade_out(self, funcs): + self._funcs_after_fade_out = funcs + + +class FadingCanvas(FadingDialog): + """The black semi transparent canvas that covers the application""" + def __init__(self, parent, opacity, duration, easing_curve, color, + tour=None): + """Create a black semi transparent canvas that covers the app.""" + super(FadingCanvas, self).__init__(parent, opacity, duration, + easing_curve) + self.parent = parent + self.tour = tour + + # Canvas color + self.color = color + # Decoration color + self.color_decoration = QColor(SpyderPalette.COLOR_ERROR_2) + # Width in pixels for decoration + self.stroke_decoration = 2 + + self.region_mask = None + self.region_subtract = None + self.region_decoration = None + + self.widgets = None # The widget to uncover + self.decoration = None # The widget to draw decoration + self.interaction_on = False + + self.path_current = None + self.path_subtract = None + self.path_full = None + self.path_decoration = None + + # widget setup + self.setWindowFlags(Qt.Dialog | Qt.FramelessWindowHint) + self.setAttribute(Qt.WA_TranslucentBackground) + self.setAttribute(Qt.WA_TransparentForMouseEvents) + self.setModal(False) + self.setFocusPolicy(Qt.NoFocus) + + self.set_funcs_before_fade_in([self.update_canvas]) + self.set_funcs_after_fade_out([lambda: self.update_widgets(None), + lambda: self.update_decoration(None)]) + + def set_interaction(self, value): + self.interaction_on = value + + def update_canvas(self): + w, h = self.parent.size().width(), self.parent.size().height() + + self.path_full = QPainterPath() + self.path_subtract = QPainterPath() + self.path_decoration = QPainterPath() + self.region_mask = QRegion(0, 0, w, h) + + self.path_full.addRect(0, 0, w, h) + # Add the path + if self.widgets is not None: + for widget in self.widgets: + temp_path = QPainterPath() + # if widget is not found... find more general way to handle + if widget is not None: + widget.raise_() + widget.show() + geo = widget.frameGeometry() + width, height = geo.width(), geo.height() + point = widget.mapTo(self.parent, QPoint(0, 0)) + x, y = point.x(), point.y() + + temp_path.addRect(QRectF(x, y, width, height)) + + temp_region = QRegion(x, y, width, height) + + if self.interaction_on: + self.region_mask = self.region_mask.subtracted(temp_region) + self.path_subtract = self.path_subtract.united(temp_path) + + self.path_current = self.path_full.subtracted(self.path_subtract) + else: + self.path_current = self.path_full + if self.decoration is not None: + for widgets in self.decoration: + if isinstance(widgets, QWidget): + widgets = [widgets] + geoms = [] + for widget in widgets: + widget.raise_() + widget.show() + geo = widget.frameGeometry() + width, height = geo.width(), geo.height() + point = widget.mapTo(self.parent, QPoint(0, 0)) + x, y = point.x(), point.y() + geoms.append((x, y, width, height)) + x = min([geom[0] for geom in geoms]) + y = min([geom[1] for geom in geoms]) + width = max([ + geom[0] + geom[2] for geom in geoms]) - x + height = max([ + geom[1] + geom[3] for geom in geoms]) - y + temp_path = QPainterPath() + temp_path.addRect(QRectF(x, y, width, height)) + + temp_region_1 = QRegion(x-1, y-1, width+2, height+2) + temp_region_2 = QRegion(x+1, y+1, width-2, height-2) + temp_region = temp_region_1.subtracted(temp_region_2) + + if self.interaction_on: + self.region_mask = self.region_mask.united(temp_region) + + self.path_decoration = self.path_decoration.united(temp_path) + else: + self.path_decoration.addRect(0, 0, 0, 0) + + # Add a decoration stroke around widget + self.setMask(self.region_mask) + self.update() + self.repaint() + + def update_widgets(self, widgets): + self.widgets = widgets + + def update_decoration(self, widgets): + self.decoration = widgets + + def paintEvent(self, event): + """Override Qt method""" + painter = QPainter(self) + painter.setRenderHint(QPainter.Antialiasing) + # Decoration + painter.fillPath(self.path_current, QBrush(self.color)) + painter.strokePath(self.path_decoration, QPen(self.color_decoration, + self.stroke_decoration)) +# decoration_fill = QColor(self.color_decoration) +# decoration_fill.setAlphaF(0.25) +# painter.fillPath(self.path_decoration, decoration_fill) + + def reject(self): + """Override Qt method""" + if not self.is_fade_running(): + key = Qt.Key_Escape + self.key_pressed = key + self.sig_key_pressed.emit() + + def mousePressEvent(self, event): + """Override Qt method""" + pass + + def focusInEvent(self, event): + """Override Qt method.""" + # To be used so tips do not appear outside spyder + if self.hasFocus(): + self.tour.gain_focus() + + def focusOutEvent(self, event): + """Override Qt method.""" + # To be used so tips do not appear outside spyder + if self.tour.step_current != 0: + self.tour.lost_focus() + + +class FadingTipBox(FadingDialog): + """Dialog that contains the text for each frame in the tour.""" + def __init__(self, parent, opacity, duration, easing_curve, tour=None, + color_top=None, color_back=None, combobox_background=None): + super(FadingTipBox, self).__init__(parent, opacity, duration, + easing_curve) + self.holder = self.anim # needed for qt to work + self.parent = parent + self.tour = tour + + self.frames = None + self.offset_shadow = 0 + self.fixed_width = 300 + + self.key_pressed = None + + self.setAttribute(Qt.WA_TranslucentBackground) + self.setWindowFlags(Qt.Dialog | Qt.FramelessWindowHint | + Qt.WindowStaysOnTopHint) + self.setModal(False) + + # Widgets + def toolbutton(icon): + bt = QToolButton() + bt.setAutoRaise(True) + bt.setIcon(icon) + return bt + + self.button_close = toolbutton(ima.icon("tour.close")) + self.button_home = toolbutton(ima.icon("tour.home")) + self.button_previous = toolbutton(ima.icon("tour.previous")) + self.button_end = toolbutton(ima.icon("tour.end")) + self.button_next = toolbutton(ima.icon("tour.next")) + self.button_run = QPushButton(_('Run code')) + self.button_disable = None + self.button_current = QToolButton() + self.label_image = QLabel() + + self.label_title = QLabel() + self.combo_title = QComboBox() + self.label_current = QLabel() + self.label_content = QLabel() + + self.label_content.setOpenExternalLinks(True) + self.label_content.setMinimumWidth(self.fixed_width) + self.label_content.setMaximumWidth(self.fixed_width) + + self.label_current.setAlignment(Qt.AlignCenter) + + self.label_content.setWordWrap(True) + + self.widgets = [self.label_content, self.label_title, + self.label_current, self.combo_title, + self.button_close, self.button_run, self.button_next, + self.button_previous, self.button_end, + self.button_home, self.button_current] + + arrow = get_image_path('hide') + + self.color_top = color_top + self.color_back = color_back + self.combobox_background = combobox_background + self.stylesheet = '''QComboBox {{ + padding-left: 5px; + background-color: {} + border-width: 0px; + border-radius: 0px; + min-height:20px; + max-height:20px; + }} + + QComboBox::drop-down {{ + subcontrol-origin: padding; + subcontrol-position: top left; + border-width: 0px; + }} + + QComboBox::down-arrow {{ + image: url({}); + }} + '''.format(self.combobox_background.name(), arrow) + # Windows fix, slashes should be always in unix-style + self.stylesheet = self.stylesheet.replace('\\', '/') + + self.setFocusPolicy(Qt.StrongFocus) + for widget in self.widgets: + widget.setFocusPolicy(Qt.NoFocus) + widget.setStyleSheet(self.stylesheet) + + layout_top = QHBoxLayout() + layout_top.addWidget(self.combo_title) + layout_top.addStretch() + layout_top.addWidget(self.button_close) + layout_top.addSpacerItem(QSpacerItem(self.offset_shadow, + self.offset_shadow)) + + layout_content = QHBoxLayout() + layout_content.addWidget(self.label_content) + layout_content.addWidget(self.label_image) + layout_content.addSpacerItem(QSpacerItem(5, 5)) + + layout_run = QHBoxLayout() + layout_run.addStretch() + layout_run.addWidget(self.button_run) + layout_run.addStretch() + layout_run.addSpacerItem(QSpacerItem(self.offset_shadow, + self.offset_shadow)) + + layout_navigation = QHBoxLayout() + layout_navigation.addWidget(self.button_home) + layout_navigation.addWidget(self.button_previous) + layout_navigation.addStretch() + layout_navigation.addWidget(self.label_current) + layout_navigation.addStretch() + layout_navigation.addWidget(self.button_next) + layout_navigation.addWidget(self.button_end) + layout_navigation.addSpacerItem(QSpacerItem(self.offset_shadow, + self.offset_shadow)) + + layout = QVBoxLayout() + layout.addLayout(layout_top) + layout.addStretch() + layout.addSpacerItem(QSpacerItem(15, 15)) + layout.addLayout(layout_content) + layout.addLayout(layout_run) + layout.addStretch() + layout.addSpacerItem(QSpacerItem(15, 15)) + layout.addLayout(layout_navigation) + layout.addSpacerItem(QSpacerItem(self.offset_shadow, + self.offset_shadow)) + + layout.setSizeConstraint(QLayout.SetFixedSize) + + self.setLayout(layout) + + self.set_funcs_before_fade_in([self._disable_widgets]) + self.set_funcs_after_fade_in([self._enable_widgets, self.setFocus]) + self.set_funcs_before_fade_out([self._disable_widgets]) + + self.setContextMenuPolicy(Qt.CustomContextMenu) + + # signals and slots + # These are defined every time by the AnimatedTour Class + + def _disable_widgets(self): + for widget in self.widgets: + widget.setDisabled(True) + + def _enable_widgets(self): + self.setWindowFlags(Qt.Dialog | Qt.FramelessWindowHint | + Qt.WindowStaysOnTopHint) + for widget in self.widgets: + widget.setDisabled(False) + + if self.button_disable == 'previous': + self.button_previous.setDisabled(True) + self.button_home.setDisabled(True) + elif self.button_disable == 'next': + self.button_next.setDisabled(True) + self.button_end.setDisabled(True) + self.button_run.setDisabled(sys.platform == "darwin") + + def set_data(self, title, content, current, image, run, frames=None, + step=None): + self.label_title.setText(title) + self.combo_title.clear() + self.combo_title.addItems(frames) + self.combo_title.setCurrentIndex(step) +# min_content_len = max([len(f) for f in frames]) +# self.combo_title.setMinimumContentsLength(min_content_len) + + # Fix and try to see how it looks with a combo box + self.label_current.setText(current) + self.button_current.setText(current) + self.label_content.setText(content) + self.image = image + + if image is None: + self.label_image.setFixedHeight(1) + self.label_image.setFixedWidth(1) + else: + extension = image.split('.')[-1] + self.image = QPixmap(get_image_path(image), extension) + self.label_image.setPixmap(self.image) + self.label_image.setFixedSize(self.image.size()) + + if run is None: + self.button_run.setVisible(False) + else: + self.button_run.setVisible(True) + if sys.platform == "darwin": + self.button_run.setToolTip("Not available on macOS") + + # Refresh layout + self.layout().activate() + + def set_pos(self, x, y): + self.x = ceil(x) + self.y = ceil(y) + self.move(QPoint(self.x, self.y)) + + def build_paths(self): + geo = self.geometry() + radius = 0 + shadow = self.offset_shadow + x0, y0 = geo.x(), geo.y() + width, height = geo.width() - shadow, geo.height() - shadow + + left, top = 0, 0 + right, bottom = width, height + + self.round_rect_path = QPainterPath() + self.round_rect_path.moveTo(right, top + radius) + self.round_rect_path.arcTo(right-radius, top, radius, radius, 0.0, + 90.0) + self.round_rect_path.lineTo(left+radius, top) + self.round_rect_path.arcTo(left, top, radius, radius, 90.0, 90.0) + self.round_rect_path.lineTo(left, bottom-radius) + self.round_rect_path.arcTo(left, bottom-radius, radius, radius, 180.0, + 90.0) + self.round_rect_path.lineTo(right-radius, bottom) + self.round_rect_path.arcTo(right-radius, bottom-radius, radius, radius, + 270.0, 90.0) + self.round_rect_path.closeSubpath() + + # Top path + header = 36 + offset = 2 + left, top = offset, offset + right = width - (offset) + self.top_rect_path = QPainterPath() + self.top_rect_path.lineTo(right, top + radius) + self.top_rect_path.moveTo(right, top + radius) + self.top_rect_path.arcTo(right-radius, top, radius, radius, 0.0, 90.0) + self.top_rect_path.lineTo(left+radius, top) + self.top_rect_path.arcTo(left, top, radius, radius, 90.0, 90.0) + self.top_rect_path.lineTo(left, top + header) + self.top_rect_path.lineTo(right, top + header) + + def paintEvent(self, event): + """Override Qt method.""" + self.build_paths() + + painter = QPainter(self) + painter.setRenderHint(QPainter.Antialiasing) + + painter.fillPath(self.round_rect_path, self.color_back) + painter.fillPath(self.top_rect_path, self.color_top) + painter.strokePath(self.round_rect_path, QPen(Qt.gray, 1)) + + # TODO: Build the pointing arrow? + + def keyReleaseEvent(self, event): + """Override Qt method.""" + key = event.key() + self.key_pressed = key + + keys = [Qt.Key_Right, Qt.Key_Left, Qt.Key_Down, Qt.Key_Up, + Qt.Key_Escape, Qt.Key_PageUp, Qt.Key_PageDown, + Qt.Key_Home, Qt.Key_End, Qt.Key_Menu] + + if key in keys: + if not self.is_fade_running(): + self.sig_key_pressed.emit() + + def mousePressEvent(self, event): + """Override Qt method.""" + # Raise the main application window on click + self.parent.raise_() + self.raise_() + + if event.button() == Qt.RightButton: + pass +# clicked_widget = self.childAt(event.x(), event.y()) +# if clicked_widget == self.label_current: +# self.context_menu_requested(event) + + def focusOutEvent(self, event): + """Override Qt method.""" + # To be used so tips do not appear outside spyder + self.tour.lost_focus() + + def context_menu_requested(self, event): + pos = QPoint(event.x(), event.y()) + menu = QMenu(self) + + actions = [] + action_title = create_action(self, _('Go to step: '), icon=QIcon()) + action_title.setDisabled(True) + actions.append(action_title) +# actions.append(create_action(self, _(': '), icon=QIcon())) + + add_actions(menu, actions) + + menu.popup(self.mapToGlobal(pos)) + + def reject(self): + """Qt method to handle escape key event""" + if not self.is_fade_running(): + key = Qt.Key_Escape + self.key_pressed = key + self.sig_key_pressed.emit() + + +class AnimatedTour(QWidget): + """Widget to display an interactive tour.""" + + def __init__(self, parent): + QWidget.__init__(self, parent) + + self.parent = parent + + # Variables to adjust + self.duration_canvas = [666, 666] + self.duration_tips = [333, 333] + self.opacity_canvas = [0.0, 0.7] + self.opacity_tips = [0.0, 1.0] + self.color = Qt.black + self.easing_curve = [QEasingCurve.Linear] + + self.current_step = 0 + self.step_current = 0 + self.steps = 0 + self.canvas = None + self.tips = None + self.frames = None + self.spy_window = None + self.initial_fullscreen_state = None + + self.widgets = None + self.dockwidgets = None + self.decoration = None + self.run = None + + self.is_tour_set = False + self.is_running = False + + # Widgets + self.canvas = FadingCanvas(self.parent, self.opacity_canvas, + self.duration_canvas, self.easing_curve, + self.color, tour=self) + self.tips = FadingTipBox(self.parent, self.opacity_tips, + self.duration_tips, self.easing_curve, + tour=self, color_top=MAIN_TOP_COLOR, + color_back=MAIN_BG_COLOR, + combobox_background=MAIN_TOP_COLOR) + + # Widgets setup + # Needed to fix spyder-ide/spyder#2204. + self.setAttribute(Qt.WA_TransparentForMouseEvents) + + # Signals and slots + self.tips.button_next.clicked.connect(self.next_step) + self.tips.button_previous.clicked.connect(self.previous_step) + self.tips.button_close.clicked.connect(self.close_tour) + self.tips.button_run.clicked.connect(self.run_code) + self.tips.button_home.clicked.connect(self.first_step) + self.tips.button_end.clicked.connect(self.last_step) + self.tips.button_run.clicked.connect( + lambda: self.tips.button_run.setDisabled(True)) + self.tips.combo_title.currentIndexChanged.connect(self.go_to_step) + + # Main window move or resize + self.parent.sig_resized.connect(self._resized) + self.parent.sig_moved.connect(self._moved) + + # To capture the arrow keys that allow moving the tour + self.tips.sig_key_pressed.connect(self._key_pressed) + + # To control the focus of tour + self.setting_data = False + self.hidden = False + + def _resized(self, event): + if self.is_running: + geom = self.parent.geometry() + self.canvas.setFixedSize(geom.width(), geom.height()) + self.canvas.update_canvas() + + if self.is_tour_set: + self._set_data() + + def _moved(self, event): + if self.is_running: + geom = self.parent.geometry() + self.canvas.move(geom.x(), geom.y()) + + if self.is_tour_set: + self._set_data() + + def _close_canvas(self): + self.tips.hide() + self.canvas.fade_out(self.canvas.hide) + + def _clear_canvas(self): + # TODO: Add option to also make it white... might be useful? + # Make canvas black before transitions + self.canvas.update_widgets(None) + self.canvas.update_decoration(None) + self.canvas.update_canvas() + + def _move_step(self): + self._set_data() + + # Show/raise the widget so it is located first! + widgets = self.dockwidgets + if widgets is not None: + widget = widgets[0] + if widget is not None: + widget.show() + widget.raise_() + + self._locate_tip_box() + + # Change in canvas only after fadein finishes, for visual aesthetics + self.tips.fade_in(self.canvas.update_canvas) + self.tips.raise_() + + def _set_modal(self, value, widgets): + platform = sys.platform.lower() + + if 'linux' in platform: + pass + elif 'win' in platform: + for widget in widgets: + widget.setModal(value) + widget.hide() + widget.show() + elif 'darwin' in platform: + pass + else: + pass + + def _process_widgets(self, names, spy_window): + widgets = [] + dockwidgets = [] + + for name in names: + try: + base = name.split('.')[0] + try: + temp = getattr(spy_window, name) + except AttributeError: + temp = None + # Check if it is the current editor + if 'get_current_editor()' in name: + temp = temp.get_current_editor() + temp = getattr(temp, name.split('.')[-1]) + if temp is None: + raise + except AttributeError: + temp = eval(f"spy_window.{name}") + + widgets.append(temp) + + # Check if it is a dockwidget and make the widget a dockwidget + # If not return the same widget + temp = getattr(temp, 'dockwidget', temp) + dockwidgets.append(temp) + + return widgets, dockwidgets + + def _set_data(self): + """Set data that is displayed in each step of the tour.""" + self.setting_data = True + step, steps, frames = self.step_current, self.steps, self.frames + current = '{0}/{1}'.format(step + 1, steps) + frame = frames[step] + + combobox_frames = [u"{0}. {1}".format(i+1, f['title']) + for i, f in enumerate(frames)] + + title, content, image = '', '', None + widgets, dockwidgets, decoration = None, None, None + run = None + + # Check if entry exists in dic and act accordingly + if 'title' in frame: + title = frame['title'] + + if 'content' in frame: + content = frame['content'] + + if 'widgets' in frame: + widget_names = frames[step]['widgets'] + # Get the widgets based on their name + widgets, dockwidgets = self._process_widgets(widget_names, + self.spy_window) + self.widgets = widgets + self.dockwidgets = dockwidgets + + if 'decoration' in frame: + widget_names = frames[step]['decoration'] + deco, decoration = self._process_widgets(widget_names, + self.spy_window) + self.decoration = decoration + + if 'image' in frame: + image = frames[step]['image'] + + if 'interact' in frame: + self.canvas.set_interaction(frame['interact']) + if frame['interact']: + self._set_modal(False, [self.tips]) + else: + self._set_modal(True, [self.tips]) + else: + self.canvas.set_interaction(False) + self._set_modal(True, [self.tips]) + + if 'run' in frame: + # Assume that the first widget is the console + run = frame['run'] + self.run = run + + self.tips.set_data(title, content, current, image, run, + frames=combobox_frames, step=step) + self._check_buttons() + + # Make canvas black when starting a new place of decoration + self.canvas.update_widgets(dockwidgets) + self.canvas.update_decoration(decoration) + self.setting_data = False + + def _locate_tip_box(self): + dockwidgets = self.dockwidgets + + # Store the dimensions of the main window + geo = self.parent.frameGeometry() + x, y, width, height = geo.x(), geo.y(), geo.width(), geo.height() + self.width_main = width + self.height_main = height + self.x_main = x + self.y_main = y + + delta = 20 + offset = 10 + + # Here is the tricky part to define the best position for the + # tip widget + if dockwidgets is not None: + if dockwidgets[0] is not None: + geo = dockwidgets[0].geometry() + x, y, width, height = (geo.x(), geo.y(), + geo.width(), geo.height()) + + point = dockwidgets[0].mapToGlobal(QPoint(0, 0)) + x_glob, y_glob = point.x(), point.y() + + # Put tip to the opposite side of the pane + if x < self.tips.width(): + x = x_glob + width + delta + y = y_glob + height/2 - self.tips.height()/2 + else: + x = x_glob - self.tips.width() - delta + y = y_glob + height/2 - self.tips.height()/2 + + if (y + self.tips.height()) > (self.y_main + self.height_main): + y = ( + y + - (y + self.tips.height() - ( + self.y_main + self.height_main)) - offset + ) + else: + # Center on parent + x = self.x_main + self.width_main/2 - self.tips.width()/2 + y = self.y_main + self.height_main/2 - self.tips.height()/2 + + self.tips.set_pos(x, y) + + def _check_buttons(self): + step, steps = self.step_current, self.steps + self.tips.button_disable = None + + if step == 0: + self.tips.button_disable = 'previous' + + if step == steps - 1: + self.tips.button_disable = 'next' + + def _key_pressed(self): + key = self.tips.key_pressed + + if ((key == Qt.Key_Right or key == Qt.Key_Down or + key == Qt.Key_PageDown) and self.step_current != self.steps - 1): + self.next_step() + elif ((key == Qt.Key_Left or key == Qt.Key_Up or + key == Qt.Key_PageUp) and self.step_current != 0): + self.previous_step() + elif key == Qt.Key_Escape: + self.close_tour() + elif key == Qt.Key_Home and self.step_current != 0: + self.first_step() + elif key == Qt.Key_End and self.step_current != self.steps - 1: + self.last_step() + elif key == Qt.Key_Menu: + pos = self.tips.label_current.pos() + self.tips.context_menu_requested(pos) + + def _hiding(self): + self.hidden = True + self.tips.hide() + + # --- public api + def run_code(self): + codelines = self.run + console = self.widgets[0] + for codeline in codelines: + console.execute_code(codeline) + + def set_tour(self, index, frames, spy_window): + self.spy_window = spy_window + self.active_tour_index = index + self.last_frame_active = frames['last'] + self.frames = frames['tour'] + self.steps = len(self.frames) + + self.is_tour_set = True + + def _handle_fullscreen(self): + if (self.spy_window.isFullScreen() or + self.spy_window.layouts._fullscreen_flag): + if sys.platform == 'darwin': + self.spy_window.setUpdatesEnabled(True) + msg_title = _("Request") + msg = _("To run the tour, please press the green button on " + "the left of the Spyder window's title bar to take " + "it out of fullscreen mode.") + QMessageBox.information(self, msg_title, msg, + QMessageBox.Ok) + return True + if self.spy_window.layouts._fullscreen_flag: + self.spy_window.layouts.toggle_fullscreen() + else: + self.spy_window.setWindowState( + self.spy_window.windowState() + & (~ Qt.WindowFullScreen)) + return False + + def start_tour(self): + self.spy_window.setUpdatesEnabled(False) + if self._handle_fullscreen(): + return + self.spy_window.layouts.save_current_window_settings( + 'layout_current_temp/', + section="quick_layouts", + ) + self.spy_window.layouts.quick_layout_switch( + DefaultLayouts.SpyderLayout) + geo = self.parent.geometry() + x, y, width, height = geo.x(), geo.y(), geo.width(), geo.height() +# self.parent_x = x +# self.parent_y = y +# self.parent_w = width +# self.parent_h = height + + # FIXME: reset step to last used value + # Reset step to beginning + self.step_current = self.last_frame_active + + # Adjust the canvas size to match the main window size + self.canvas.setFixedSize(width, height) + self.canvas.move(QPoint(x, y)) + self.spy_window.setUpdatesEnabled(True) + self.canvas.fade_in(self._move_step) + self._clear_canvas() + + self.is_running = True + + def close_tour(self): + self.tips.fade_out(self._close_canvas) + self.spy_window.setUpdatesEnabled(False) + self.canvas.set_interaction(False) + self._set_modal(True, [self.tips]) + self.canvas.hide() + + try: + # set the last played frame by updating the available tours in + # parent. This info will be lost on restart. + self.parent.tours_available[self.active_tour_index]['last'] =\ + self.step_current + except Exception: + pass + + self.is_running = False + self.spy_window.layouts.quick_layout_switch('current_temp') + self.spy_window.setUpdatesEnabled(True) + + def hide_tips(self): + """Hide tips dialog when the main window loses focus.""" + self._clear_canvas() + self.tips.fade_out(self._hiding) + + def unhide_tips(self): + """Unhide tips dialog when the main window loses focus.""" + self._clear_canvas() + self._move_step() + self.hidden = False + + def next_step(self): + self._clear_canvas() + self.step_current += 1 + self.tips.fade_out(self._move_step) + + def previous_step(self): + self._clear_canvas() + self.step_current -= 1 + self.tips.fade_out(self._move_step) + + def go_to_step(self, number, id_=None): + self._clear_canvas() + self.step_current = number + self.tips.fade_out(self._move_step) + + def last_step(self): + self.go_to_step(self.steps - 1) + + def first_step(self): + self.go_to_step(0) + + def lost_focus(self): + """Confirm if the tour loses focus and hides the tips.""" + if (self.is_running and + not self.setting_data and not self.hidden): + if sys.platform == 'darwin': + if not self.tour_has_focus(): + self.hide_tips() + if not self.any_has_focus(): + self.close_tour() + else: + if not self.any_has_focus(): + self.hide_tips() + + def gain_focus(self): + """Confirm if the tour regains focus and unhides the tips.""" + if (self.is_running and self.any_has_focus() and + not self.setting_data and self.hidden): + self.unhide_tips() + + def any_has_focus(self): + """Returns True if tour or main window has focus.""" + f = (self.hasFocus() or self.parent.hasFocus() or + self.tour_has_focus() or self.isActiveWindow()) + return f + + def tour_has_focus(self): + """Returns true if tour or any of its components has focus.""" + f = (self.tips.hasFocus() or self.canvas.hasFocus() or + self.tips.isActiveWindow()) + return f + + +class OpenTourDialog(QDialog): + """Initial widget with tour.""" + + def __init__(self, parent, tour_function): + super().__init__(parent) + if MAC: + flags = (self.windowFlags() | Qt.WindowStaysOnTopHint + & ~Qt.WindowContextHelpButtonHint) + else: + flags = self.windowFlags() & ~Qt.WindowContextHelpButtonHint + self.setWindowFlags(flags) + self.tour_function = tour_function + + # Image + images_layout = QHBoxLayout() + icon_filename = 'tour-spyder-logo' + image_path = get_image_path(icon_filename) + image = QPixmap(image_path) + image_label = QLabel() + image_height = int(image.height() * DialogStyle.IconScaleFactor) + image_width = int(image.width() * DialogStyle.IconScaleFactor) + image = image.scaled(image_width, image_height, Qt.KeepAspectRatio, + Qt.SmoothTransformation) + image_label.setPixmap(image) + + images_layout.addStretch() + images_layout.addWidget(image_label) + images_layout.addStretch() + if MAC: + images_layout.setContentsMargins(0, -5, 20, 0) + else: + images_layout.setContentsMargins(0, -8, 35, 0) + + # Label + tour_label_title = QLabel(_("Welcome to Spyder!")) + tour_label_title.setStyleSheet(f"font-size: {DialogStyle.TitleFontSize}") + tour_label_title.setWordWrap(True) + tour_label = QLabel( + _("Check out our interactive tour to " + "explore some of Spyder's panes and features.")) + tour_label.setStyleSheet(f"font-size: {DialogStyle.ContentFontSize}") + tour_label.setWordWrap(True) + tour_label.setFixedWidth(340) + + # Buttons + buttons_layout = QHBoxLayout() + dialog_tour_color = QStylePalette.COLOR_BACKGROUND_2 + start_tour_color = QStylePalette.COLOR_ACCENT_2 + start_tour_hover = QStylePalette.COLOR_ACCENT_3 + start_tour_pressed = QStylePalette.COLOR_ACCENT_4 + dismiss_tour_color = QStylePalette.COLOR_BACKGROUND_4 + dismiss_tour_hover = QStylePalette.COLOR_BACKGROUND_5 + dismiss_tour_pressed = QStylePalette.COLOR_BACKGROUND_6 + font_color = QStylePalette.COLOR_TEXT_1 + self.launch_tour_button = QPushButton(_('Start tour')) + self.launch_tour_button.setStyleSheet(( + "QPushButton {{ " + "background-color: {background_color};" + "border-color: {border_color};" + "font-size: {font_size};" + "color: {font_color};" + "padding: {padding}}}" + "QPushButton:hover:!pressed {{ " + "background-color: {color_hover}}}" + "QPushButton:pressed {{ " + "background-color: {color_pressed}}}" + ).format(background_color=start_tour_color, + border_color=start_tour_color, + font_size=DialogStyle.ButtonsFontSize, + font_color=font_color, + padding=DialogStyle.ButtonsPadding, + color_hover=start_tour_hover, + color_pressed=start_tour_pressed)) + self.launch_tour_button.setAutoDefault(False) + self.dismiss_button = QPushButton(_('Dismiss')) + self.dismiss_button.setStyleSheet(( + "QPushButton {{ " + "background-color: {background_color};" + "border-color: {border_color};" + "font-size: {font_size};" + "color: {font_color};" + "padding: {padding}}}" + "QPushButton:hover:!pressed {{ " + "background-color: {color_hover}}}" + "QPushButton:pressed {{ " + "background-color: {color_pressed}}}" + ).format(background_color=dismiss_tour_color, + border_color=dismiss_tour_color, + font_size=DialogStyle.ButtonsFontSize, + font_color=font_color, + padding=DialogStyle.ButtonsPadding, + color_hover=dismiss_tour_hover, + color_pressed=dismiss_tour_pressed)) + self.dismiss_button.setAutoDefault(False) + + buttons_layout.addStretch() + buttons_layout.addWidget(self.launch_tour_button) + if not MAC: + buttons_layout.addSpacing(10) + buttons_layout.addWidget(self.dismiss_button) + + layout = QHBoxLayout() + layout.addLayout(images_layout) + + label_layout = QVBoxLayout() + label_layout.addWidget(tour_label_title) + if not MAC: + label_layout.addSpacing(3) + label_layout.addWidget(tour_label) + else: + label_layout.addWidget(tour_label) + label_layout.addSpacing(10) + + vertical_layout = QVBoxLayout() + if not MAC: + vertical_layout.addStretch() + vertical_layout.addLayout(label_layout) + vertical_layout.addSpacing(20) + vertical_layout.addLayout(buttons_layout) + vertical_layout.addStretch() + else: + vertical_layout.addLayout(label_layout) + vertical_layout.addLayout(buttons_layout) + + general_layout = QHBoxLayout() + if not MAC: + general_layout.addStretch() + general_layout.addLayout(layout) + general_layout.addSpacing(1) + general_layout.addLayout(vertical_layout) + general_layout.addStretch() + else: + general_layout.addLayout(layout) + general_layout.addLayout(vertical_layout) + + self.setLayout(general_layout) + + self.launch_tour_button.clicked.connect(self._start_tour) + self.dismiss_button.clicked.connect(self.close) + self.setStyleSheet(f"background-color:{dialog_tour_color}") + self.setContentsMargins(18, 40, 18, 40) + if not MAC: + self.setFixedSize(640, 280) + + def _start_tour(self): + self.close() + self.tour_function() + + +# ---------------------------------------------------------------------------- +# Used for testing the functionality +# ---------------------------------------------------------------------------- + +class TourTestWindow(QMainWindow): + """ """ + sig_resized = Signal("QResizeEvent") + sig_moved = Signal("QMoveEvent") + + def __init__(self): + super(TourTestWindow, self).__init__() + self.setGeometry(300, 100, 400, 600) + self.setWindowTitle('Exploring QMainWindow') + + self.exit = QAction('Exit', self) + self.exit.setStatusTip('Exit program') + + # create the menu bar + menubar = self.menuBar() + file_ = menubar.addMenu('&File') + file_.addAction(self.exit) + + # create the status bar + self.statusBar() + + # QWidget or its instance needed for box layout + self.widget = QWidget(self) + + self.button = QPushButton('test') + self.button1 = QPushButton('1') + self.button2 = QPushButton('2') + + effect = QGraphicsOpacityEffect(self.button2) + self.button2.setGraphicsEffect(effect) + self.anim = QPropertyAnimation(effect, to_binary_string("opacity")) + self.anim.setStartValue(0.01) + self.anim.setEndValue(1.0) + self.anim.setDuration(500) + + lay = QVBoxLayout() + lay.addWidget(self.button) + lay.addStretch() + lay.addWidget(self.button1) + lay.addWidget(self.button2) + + self.widget.setLayout(lay) + + self.setCentralWidget(self.widget) + self.button.clicked.connect(self.action1) + self.button1.clicked.connect(self.action2) + + self.tour = AnimatedTour(self) + + def action1(self): + frames = get_tour('test') + index = 0 + dic = {'last': 0, 'tour': frames} + self.tour.set_tour(index, dic, self) + self.tour.start_tour() + + def action2(self): + self.anim.start() + + def resizeEvent(self, event): + """Reimplement Qt method""" + QMainWindow.resizeEvent(self, event) + self.sig_resized.emit(event) + + def moveEvent(self, event): + """Reimplement Qt method""" + QMainWindow.moveEvent(self, event) + self.sig_moved.emit(event) + + +def local_test(): + from spyder.utils.qthelpers import qapplication + + app = QApplication([]) + win = TourTestWindow() + win.show() + app.exec_() + + +if __name__ == '__main__': + local_test() diff --git a/spyder/plugins/variableexplorer/api.py b/spyder/plugins/variableexplorer/api.py index 0eeaef662dd..1fdda8880d2 100644 --- a/spyder/plugins/variableexplorer/api.py +++ b/spyder/plugins/variableexplorer/api.py @@ -1,14 +1,14 @@ -# -*- coding: utf-8 -*- -# -# Copyright © Spyder Project Contributors -# Licensed under the terms of the MIT License -# (see spyder/__init__.py for details) - -""" -Variable Explorer Plugin API. -""" - -# Local imports -from spyder.plugins.variableexplorer.widgets.main_widget import ( - VariableExplorerWidgetActions, VariableExplorerWidgetMainToolBarSections, - VariableExplorerWidgetMenus, VariableExplorerWidgetOptionsMenuSections) +# -*- coding: utf-8 -*- +# +# Copyright © Spyder Project Contributors +# Licensed under the terms of the MIT License +# (see spyder/__init__.py for details) + +""" +Variable Explorer Plugin API. +""" + +# Local imports +from spyder.plugins.variableexplorer.widgets.main_widget import ( + VariableExplorerWidgetActions, VariableExplorerWidgetMainToolBarSections, + VariableExplorerWidgetMenus, VariableExplorerWidgetOptionsMenuSections) diff --git a/spyder/plugins/variableexplorer/plugin.py b/spyder/plugins/variableexplorer/plugin.py index f84041ef116..ff78e266b97 100644 --- a/spyder/plugins/variableexplorer/plugin.py +++ b/spyder/plugins/variableexplorer/plugin.py @@ -1,82 +1,82 @@ -# -*- coding: utf-8 -*- -# -# Copyright © Spyder Project Contributors -# Licensed under the terms of the MIT License -# (see spyder/__init__.py for details) - -""" -Variable Explorer Plugin. -""" - -# Local imports -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.translations import get_translation -from spyder.plugins.variableexplorer.confpage import ( - VariableExplorerConfigPage) -from spyder.plugins.variableexplorer.widgets.main_widget import ( - VariableExplorerWidget) - - -# Localization -_ = get_translation('spyder') - - -class VariableExplorer(SpyderDockablePlugin, ShellConnectMixin): - """ - Variable explorer plugin. - """ - NAME = 'variable_explorer' - REQUIRES = [Plugins.IPythonConsole, Plugins.Preferences] - TABIFY = None - WIDGET_CLASS = VariableExplorerWidget - CONF_SECTION = NAME - CONF_FILE = False - CONF_WIDGET_CLASS = VariableExplorerConfigPage - DISABLE_ACTIONS_WHEN_HIDDEN = False - - # ---- SpyderDockablePlugin API - # ------------------------------------------------------------------------ - @staticmethod - def get_name(): - return _('Variable explorer') - - def get_description(self): - return _('Display, explore load and save variables in the current ' - 'namespace.') - - def get_icon(self): - return self.create_icon('dictedit') - - def on_initialize(self): - self.get_widget().sig_free_memory_requested.connect( - self.sig_free_memory_requested) - - @on_plugin_available(plugin=Plugins.Preferences) - def on_preferences_available(self): - preferences = self.get_plugin(Plugins.Preferences) - preferences.register_plugin_preferences(self) - - @on_plugin_teardown(plugin=Plugins.Preferences) - def on_preferences_teardown(self): - preferences = self.get_plugin(Plugins.Preferences) - preferences.deregister_plugin_preferences(self) - - # ---- Public API - # ------------------------------------------------------------------------ - def current_widget(self): - """ - Return the current widget displayed at the moment. - - Returns - ------- - spyder.plugins.plots.widgets.namespacebrowser.NamespaceBrowser - """ - return self.get_widget().current_widget() - - def on_connection_to_external_spyder_kernel(self, shellwidget): - """Send namespace view settings to the kernel.""" - shellwidget.set_namespace_view_settings() - shellwidget.refresh_namespacebrowser() +# -*- coding: utf-8 -*- +# +# Copyright © Spyder Project Contributors +# Licensed under the terms of the MIT License +# (see spyder/__init__.py for details) + +""" +Variable Explorer Plugin. +""" + +# Local imports +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.translations import get_translation +from spyder.plugins.variableexplorer.confpage import ( + VariableExplorerConfigPage) +from spyder.plugins.variableexplorer.widgets.main_widget import ( + VariableExplorerWidget) + + +# Localization +_ = get_translation('spyder') + + +class VariableExplorer(SpyderDockablePlugin, ShellConnectMixin): + """ + Variable explorer plugin. + """ + NAME = 'variable_explorer' + REQUIRES = [Plugins.IPythonConsole, Plugins.Preferences] + TABIFY = None + WIDGET_CLASS = VariableExplorerWidget + CONF_SECTION = NAME + CONF_FILE = False + CONF_WIDGET_CLASS = VariableExplorerConfigPage + DISABLE_ACTIONS_WHEN_HIDDEN = False + + # ---- SpyderDockablePlugin API + # ------------------------------------------------------------------------ + @staticmethod + def get_name(): + return _('Variable explorer') + + def get_description(self): + return _('Display, explore load and save variables in the current ' + 'namespace.') + + def get_icon(self): + return self.create_icon('dictedit') + + def on_initialize(self): + self.get_widget().sig_free_memory_requested.connect( + self.sig_free_memory_requested) + + @on_plugin_available(plugin=Plugins.Preferences) + def on_preferences_available(self): + preferences = self.get_plugin(Plugins.Preferences) + preferences.register_plugin_preferences(self) + + @on_plugin_teardown(plugin=Plugins.Preferences) + def on_preferences_teardown(self): + preferences = self.get_plugin(Plugins.Preferences) + preferences.deregister_plugin_preferences(self) + + # ---- Public API + # ------------------------------------------------------------------------ + def current_widget(self): + """ + Return the current widget displayed at the moment. + + Returns + ------- + spyder.plugins.plots.widgets.namespacebrowser.NamespaceBrowser + """ + return self.get_widget().current_widget() + + def on_connection_to_external_spyder_kernel(self, shellwidget): + """Send namespace view settings to the kernel.""" + shellwidget.set_namespace_view_settings() + shellwidget.refresh_namespacebrowser() diff --git a/spyder/plugins/variableexplorer/widgets/arrayeditor.py b/spyder/plugins/variableexplorer/widgets/arrayeditor.py index 0bc666819e2..40497a94ec0 100644 --- a/spyder/plugins/variableexplorer/widgets/arrayeditor.py +++ b/spyder/plugins/variableexplorer/widgets/arrayeditor.py @@ -1,939 +1,939 @@ -# -*- coding: utf-8 -*- -# -# Copyright © Spyder Project Contributors -# Licensed under the terms of the MIT License -# (see spyder/__init__.py for details) - -""" -NumPy Array Editor Dialog based on Qt -""" - -# pylint: disable=C0103 -# pylint: disable=R0903 -# pylint: disable=R0911 -# pylint: disable=R0201 - -# Standard library imports -from __future__ import print_function - -# Third party imports -from qtpy.compat import from_qvariant, to_qvariant -from qtpy.QtCore import (QAbstractTableModel, QItemSelection, QLocale, - QItemSelectionRange, QModelIndex, Qt, Slot) -from qtpy.QtGui import QColor, QCursor, QDoubleValidator, QKeySequence -from qtpy.QtWidgets import (QAbstractItemDelegate, QApplication, QCheckBox, - QComboBox, QDialog, QGridLayout, QHBoxLayout, - QInputDialog, QItemDelegate, QLabel, QLineEdit, - QMenu, QMessageBox, QPushButton, QSpinBox, - QStackedWidget, QTableView, QVBoxLayout, - QWidget) -from spyder_kernels.utils.nsview import value_to_display -from spyder_kernels.utils.lazymodules import numpy as np - -# Local imports -from spyder.config.base import _ -from spyder.config.fonts import DEFAULT_SMALL_DELTA -from spyder.config.gui import get_font -from spyder.config.manager import CONF -from spyder.py3compat import (io, is_binary_string, is_string, - is_text_string, PY3, to_binary_string, - to_text_string) -from spyder.utils.icon_manager import ima -from spyder.utils.qthelpers import add_actions, create_action, keybinding -from spyder.plugins.variableexplorer.widgets.basedialog import BaseDialog - -# Note: string and unicode data types will be formatted with '%s' (see below) -SUPPORTED_FORMATS = { - 'single': '%.6g', - 'double': '%.6g', - 'float_': '%.6g', - 'longfloat': '%.6g', - 'float16': '%.6g', - 'float32': '%.6g', - 'float64': '%.6g', - 'float96': '%.6g', - 'float128': '%.6g', - 'csingle': '%r', - 'complex_': '%r', - 'clongfloat': '%r', - 'complex64': '%r', - 'complex128': '%r', - 'complex192': '%r', - 'complex256': '%r', - 'byte': '%d', - 'bytes8': '%s', - 'short': '%d', - 'intc': '%d', - 'int_': '%d', - 'longlong': '%d', - 'intp': '%d', - 'int8': '%d', - 'int16': '%d', - 'int32': '%d', - 'int64': '%d', - 'ubyte': '%d', - 'ushort': '%d', - 'uintc': '%d', - 'uint': '%d', - 'ulonglong': '%d', - 'uintp': '%d', - 'uint8': '%d', - 'uint16': '%d', - 'uint32': '%d', - 'uint64': '%d', - 'bool_': '%r', - 'bool8': '%r', - 'bool': '%r', -} - - -LARGE_SIZE = 5e5 -LARGE_NROWS = 1e5 -LARGE_COLS = 60 - - -#============================================================================== -# Utility functions -#============================================================================== -def is_float(dtype): - """Return True if datatype dtype is a float kind""" - return ('float' in dtype.name) or dtype.name in ['single', 'double'] - - -def is_number(dtype): - """Return True is datatype dtype is a number kind""" - return is_float(dtype) or ('int' in dtype.name) or ('long' in dtype.name) \ - or ('short' in dtype.name) - - -def get_idx_rect(index_list): - """Extract the boundaries from a list of indexes""" - rows, cols = list(zip(*[(i.row(), i.column()) for i in index_list])) - return ( min(rows), max(rows), min(cols), max(cols) ) - - -#============================================================================== -# Main classes -#============================================================================== -class ArrayModel(QAbstractTableModel): - """Array Editor Table Model""" - - ROWS_TO_LOAD = 500 - COLS_TO_LOAD = 40 - - def __init__(self, data, format="%.6g", xlabels=None, ylabels=None, - readonly=False, parent=None): - QAbstractTableModel.__init__(self) - - self.dialog = parent - self.changes = {} - self.xlabels = xlabels - self.ylabels = ylabels - self.readonly = readonly - self.test_array = np.array([0], dtype=data.dtype) - - # for complex numbers, shading will be based on absolute value - # but for all other types it will be the real part - if data.dtype in (np.complex64, np.complex128): - self.color_func = np.abs - else: - self.color_func = np.real - - # Backgroundcolor settings - huerange = [.66, .99] # Hue - self.sat = .7 # Saturation - self.val = 1. # Value - self.alp = .6 # Alpha-channel - - self._data = data - self._format = format - - self.total_rows = self._data.shape[0] - self.total_cols = self._data.shape[1] - size = self.total_rows * self.total_cols - - if not self._data.dtype.name == 'object': - try: - self.vmin = np.nanmin(self.color_func(data)) - self.vmax = np.nanmax(self.color_func(data)) - if self.vmax == self.vmin: - self.vmin -= 1 - self.hue0 = huerange[0] - self.dhue = huerange[1]-huerange[0] - self.bgcolor_enabled = True - except (AttributeError, TypeError, ValueError): - self.vmin = None - self.vmax = None - self.hue0 = None - self.dhue = None - self.bgcolor_enabled = False - - # Array with infinite values cannot display background colors and - # crashes. See: spyder-ide/spyder#8093 - self.has_inf = False - if data.dtype.kind in ['f', 'c']: - self.has_inf = np.any(np.isinf(data)) - - # Deactivate coloring for object arrays or arrays with inf values - if self._data.dtype.name == 'object' or self.has_inf: - self.bgcolor_enabled = False - - # Use paging when the total size, number of rows or number of - # columns is too large - if size > LARGE_SIZE: - self.rows_loaded = self.ROWS_TO_LOAD - self.cols_loaded = self.COLS_TO_LOAD - else: - if self.total_rows > LARGE_NROWS: - self.rows_loaded = self.ROWS_TO_LOAD - else: - self.rows_loaded = self.total_rows - if self.total_cols > LARGE_COLS: - self.cols_loaded = self.COLS_TO_LOAD - else: - self.cols_loaded = self.total_cols - - def get_format(self): - """Return current format""" - # Avoid accessing the private attribute _format from outside - return self._format - - def get_data(self): - """Return data""" - return self._data - - def set_format(self, format): - """Change display format""" - self._format = format - self.reset() - - def columnCount(self, qindex=QModelIndex()): - """Array column number""" - if self.total_cols <= self.cols_loaded: - return self.total_cols - else: - return self.cols_loaded - - def rowCount(self, qindex=QModelIndex()): - """Array row number""" - if self.total_rows <= self.rows_loaded: - return self.total_rows - else: - return self.rows_loaded - - def can_fetch_more(self, rows=False, columns=False): - if rows: - if self.total_rows > self.rows_loaded: - return True - else: - return False - if columns: - if self.total_cols > self.cols_loaded: - return True - else: - return False - - def fetch_more(self, rows=False, columns=False): - if self.can_fetch_more(rows=rows): - reminder = self.total_rows - self.rows_loaded - items_to_fetch = min(reminder, self.ROWS_TO_LOAD) - self.beginInsertRows(QModelIndex(), self.rows_loaded, - self.rows_loaded + items_to_fetch - 1) - self.rows_loaded += items_to_fetch - self.endInsertRows() - if self.can_fetch_more(columns=columns): - reminder = self.total_cols - self.cols_loaded - items_to_fetch = min(reminder, self.COLS_TO_LOAD) - self.beginInsertColumns(QModelIndex(), self.cols_loaded, - self.cols_loaded + items_to_fetch - 1) - self.cols_loaded += items_to_fetch - self.endInsertColumns() - - def bgcolor(self, state): - """Toggle backgroundcolor""" - self.bgcolor_enabled = state > 0 - self.reset() - - def get_value(self, index): - i = index.row() - j = index.column() - if len(self._data.shape) == 1: - value = self._data[j] - else: - value = self._data[i, j] - return self.changes.get((i, j), value) - - def data(self, index, role=Qt.DisplayRole): - """Cell content.""" - if not index.isValid(): - return to_qvariant() - value = self.get_value(index) - dtn = self._data.dtype.name - - # Tranform binary string to unicode so they are displayed - # correctly - if is_binary_string(value): - try: - value = to_text_string(value, 'utf8') - except Exception: - pass - - # Handle roles - if role == Qt.DisplayRole: - if value is np.ma.masked: - return '' - else: - if dtn == 'object': - # We don't know what's inside an object array, so - # we can't trust value repr's here. - return value_to_display(value) - else: - try: - return to_qvariant(self._format % value) - except TypeError: - self.readonly = True - return repr(value) - elif role == Qt.TextAlignmentRole: - return to_qvariant(int(Qt.AlignCenter|Qt.AlignVCenter)) - elif (role == Qt.BackgroundColorRole and self.bgcolor_enabled - and value is not np.ma.masked and not self.has_inf): - try: - hue = (self.hue0 + - self.dhue * (float(self.vmax) - self.color_func(value)) - / (float(self.vmax) - self.vmin)) - hue = float(np.abs(hue)) - color = QColor.fromHsvF(hue, self.sat, self.val, self.alp) - return to_qvariant(color) - except (TypeError, ValueError): - return to_qvariant() - elif role == Qt.FontRole: - return to_qvariant(get_font(font_size_delta=DEFAULT_SMALL_DELTA)) - return to_qvariant() - - def setData(self, index, value, role=Qt.EditRole): - """Cell content change""" - if not index.isValid() or self.readonly: - return False - i = index.row() - j = index.column() - value = from_qvariant(value, str) - dtype = self._data.dtype.name - if dtype == "bool": - try: - val = bool(float(value)) - except ValueError: - val = value.lower() == "true" - elif dtype.startswith("string") or dtype.startswith("bytes"): - val = to_binary_string(value, 'utf8') - elif dtype.startswith("unicode") or dtype.startswith("str"): - val = to_text_string(value) - else: - if value.lower().startswith('e') or value.lower().endswith('e'): - return False - try: - val = complex(value) - if not val.imag: - val = val.real - except ValueError as e: - QMessageBox.critical(self.dialog, "Error", - "Value error: %s" % str(e)) - return False - try: - self.test_array[0] = val # will raise an Exception eventually - except OverflowError as e: - print("OverflowError: " + str(e)) # spyder: test-skip - QMessageBox.critical(self.dialog, "Error", - "Overflow error: %s" % str(e)) - return False - - # Add change to self.changes - self.changes[(i, j)] = val - self.dataChanged.emit(index, index) - - if not is_string(val): - val = self.color_func(val) - - if val > self.vmax: - self.vmax = val - - if val < self.vmin: - self.vmin = val - - return True - - def flags(self, index): - """Set editable flag""" - if not index.isValid(): - return Qt.ItemIsEnabled - return Qt.ItemFlags(int(QAbstractTableModel.flags(self, index) | - Qt.ItemIsEditable)) - - def headerData(self, section, orientation, role=Qt.DisplayRole): - """Set header data""" - if role != Qt.DisplayRole: - return to_qvariant() - labels = self.xlabels if orientation == Qt.Horizontal else self.ylabels - if labels is None: - return to_qvariant(int(section)) - else: - return to_qvariant(labels[section]) - - def reset(self): - self.beginResetModel() - self.endResetModel() - - -class ArrayDelegate(QItemDelegate): - """Array Editor Item Delegate""" - def __init__(self, dtype, parent=None): - QItemDelegate.__init__(self, parent) - self.dtype = dtype - - def createEditor(self, parent, option, index): - """Create editor widget""" - model = index.model() - value = model.get_value(index) - if type(value) == np.ndarray or model.readonly: - # The editor currently cannot properly handle this case - return - elif model._data.dtype.name == "bool": - value = not value - model.setData(index, to_qvariant(value)) - return - elif value is not np.ma.masked: - editor = QLineEdit(parent) - editor.setFont(get_font(font_size_delta=DEFAULT_SMALL_DELTA)) - editor.setAlignment(Qt.AlignCenter) - if is_number(self.dtype): - validator = QDoubleValidator(editor) - validator.setLocale(QLocale('C')) - editor.setValidator(validator) - editor.returnPressed.connect(self.commitAndCloseEditor) - return editor - - def commitAndCloseEditor(self): - """Commit and close editor""" - editor = self.sender() - # Avoid a segfault with PyQt5. Variable value won't be changed - # but at least Spyder won't crash. It seems generated by a bug in sip. - try: - self.commitData.emit(editor) - except AttributeError: - pass - self.closeEditor.emit(editor, QAbstractItemDelegate.NoHint) - - def setEditorData(self, editor, index): - """Set editor widget's data""" - text = from_qvariant(index.model().data(index, Qt.DisplayRole), str) - editor.setText(text) - - -#TODO: Implement "Paste" (from clipboard) feature -class ArrayView(QTableView): - """Array view class""" - def __init__(self, parent, model, dtype, shape): - QTableView.__init__(self, parent) - - self.setModel(model) - self.setItemDelegate(ArrayDelegate(dtype, self)) - total_width = 0 - for k in range(shape[1]): - total_width += self.columnWidth(k) - self.viewport().resize(min(total_width, 1024), self.height()) - self.shape = shape - self.menu = self.setup_menu() - CONF.config_shortcut( - self.copy, - context='variable_explorer', - name='copy', - parent=self) - self.horizontalScrollBar().valueChanged.connect( - self._load_more_columns) - self.verticalScrollBar().valueChanged.connect(self._load_more_rows) - - def _load_more_columns(self, value): - """Load more columns to display.""" - # Needed to avoid a NameError while fetching data when closing - # See spyder-ide/spyder#12034. - try: - self.load_more_data(value, columns=True) - except NameError: - pass - - def _load_more_rows(self, value): - """Load more rows to display.""" - # Needed to avoid a NameError while fetching data when closing - # See spyder-ide/spyder#12034. - try: - self.load_more_data(value, rows=True) - except NameError: - pass - - def load_more_data(self, value, rows=False, columns=False): - - try: - old_selection = self.selectionModel().selection() - old_rows_loaded = old_cols_loaded = None - - if rows and value == self.verticalScrollBar().maximum(): - old_rows_loaded = self.model().rows_loaded - self.model().fetch_more(rows=rows) - - if columns and value == self.horizontalScrollBar().maximum(): - old_cols_loaded = self.model().cols_loaded - self.model().fetch_more(columns=columns) - - if old_rows_loaded is not None or old_cols_loaded is not None: - # if we've changed anything, update selection - new_selection = QItemSelection() - for part in old_selection: - top = part.top() - bottom = part.bottom() - if (old_rows_loaded is not None and - top == 0 and bottom == (old_rows_loaded-1)): - # complete column selected (so expand it to match - # updated range) - bottom = self.model().rows_loaded-1 - left = part.left() - right = part.right() - if (old_cols_loaded is not None - and left == 0 and right == (old_cols_loaded-1)): - # compete row selected (so expand it to match updated - # range) - right = self.model().cols_loaded-1 - top_left = self.model().index(top, left) - bottom_right = self.model().index(bottom, right) - part = QItemSelectionRange(top_left, bottom_right) - new_selection.append(part) - self.selectionModel().select( - new_selection, self.selectionModel().ClearAndSelect) - except NameError: - # Needed to handle a NameError while fetching data when closing - # See isue 7880 - pass - - def resize_to_contents(self): - """Resize cells to contents""" - QApplication.setOverrideCursor(QCursor(Qt.WaitCursor)) - self.resizeColumnsToContents() - self.model().fetch_more(columns=True) - self.resizeColumnsToContents() - QApplication.restoreOverrideCursor() - - def setup_menu(self): - """Setup context menu""" - self.copy_action = create_action(self, _('Copy'), - shortcut=keybinding('Copy'), - icon=ima.icon('editcopy'), - triggered=self.copy, - context=Qt.WidgetShortcut) - menu = QMenu(self) - add_actions(menu, [self.copy_action, ]) - return menu - - def contextMenuEvent(self, event): - """Reimplement Qt method""" - self.menu.popup(event.globalPos()) - event.accept() - - def keyPressEvent(self, event): - """Reimplement Qt method""" - if event == QKeySequence.Copy: - self.copy() - else: - QTableView.keyPressEvent(self, event) - - def _sel_to_text(self, cell_range): - """Copy an array portion to a unicode string""" - if not cell_range: - return - row_min, row_max, col_min, col_max = get_idx_rect(cell_range) - if col_min == 0 and col_max == (self.model().cols_loaded-1): - # we've selected a whole column. It isn't possible to - # select only the first part of a column without loading more, - # so we can treat it as intentional and copy the whole thing - col_max = self.model().total_cols-1 - if row_min == 0 and row_max == (self.model().rows_loaded-1): - row_max = self.model().total_rows-1 - - _data = self.model().get_data() - if PY3: - output = io.BytesIO() - else: - output = io.StringIO() - try: - np.savetxt(output, _data[row_min:row_max+1, col_min:col_max+1], - delimiter='\t', fmt=self.model().get_format()) - except: - QMessageBox.warning(self, _("Warning"), - _("It was not possible to copy values for " - "this array")) - return - contents = output.getvalue().decode('utf-8') - output.close() - return contents - - @Slot() - def copy(self): - """Copy text to clipboard""" - cliptxt = self._sel_to_text( self.selectedIndexes() ) - clipboard = QApplication.clipboard() - clipboard.setText(cliptxt) - - -class ArrayEditorWidget(QWidget): - - def __init__(self, parent, data, readonly=False, - xlabels=None, ylabels=None): - QWidget.__init__(self, parent) - self.data = data - self.old_data_shape = None - if len(self.data.shape) == 1: - self.old_data_shape = self.data.shape - self.data.shape = (self.data.shape[0], 1) - elif len(self.data.shape) == 0: - self.old_data_shape = self.data.shape - self.data.shape = (1, 1) - - format = SUPPORTED_FORMATS.get(data.dtype.name, '%s') - self.model = ArrayModel(self.data, format=format, xlabels=xlabels, - ylabels=ylabels, readonly=readonly, parent=self) - self.view = ArrayView(self, self.model, data.dtype, data.shape) - - layout = QVBoxLayout() - layout.addWidget(self.view) - self.setLayout(layout) - - def accept_changes(self): - """Accept changes""" - for (i, j), value in list(self.model.changes.items()): - self.data[i, j] = value - if self.old_data_shape is not None: - self.data.shape = self.old_data_shape - - def reject_changes(self): - """Reject changes""" - if self.old_data_shape is not None: - self.data.shape = self.old_data_shape - - def change_format(self): - """Change display format""" - format, valid = QInputDialog.getText(self, _( 'Format'), - _( "Float formatting"), - QLineEdit.Normal, self.model.get_format()) - if valid: - format = str(format) - try: - format % 1.1 - except: - QMessageBox.critical(self, _("Error"), - _("Format (%s) is incorrect") % format) - return - self.model.set_format(format) - - -class ArrayEditor(BaseDialog): - """Array Editor Dialog""" - def __init__(self, parent=None): - super().__init__(parent) - - # 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.data = None - self.arraywidget = None - self.stack = None - self.layout = None - self.btn_save_and_close = None - self.btn_close = None - # Values for 3d array editor - self.dim_indexes = [{}, {}, {}] - self.last_dim = 0 # Adjust this for changing the startup dimension - - def setup_and_check(self, data, title='', readonly=False, - xlabels=None, ylabels=None): - """ - Setup ArrayEditor: - return False if data is not supported, True otherwise - """ - self.data = data - readonly = readonly or not self.data.flags.writeable - is_record_array = data.dtype.names is not None - is_masked_array = isinstance(data, np.ma.MaskedArray) - - if data.ndim > 3: - self.error(_("Arrays with more than 3 dimensions are not " - "supported")) - return False - if xlabels is not None and len(xlabels) != self.data.shape[1]: - self.error(_("The 'xlabels' argument length do no match array " - "column number")) - return False - if ylabels is not None and len(ylabels) != self.data.shape[0]: - self.error(_("The 'ylabels' argument length do no match array row " - "number")) - return False - if not is_record_array: - dtn = data.dtype.name - if dtn == 'object': - # If the array doesn't have shape, we can't display it - if data.shape == (): - self.error(_("Object arrays without shape are not " - "supported")) - return False - # We don't know what's inside these arrays, so we can't handle - # edits - self.readonly = readonly = True - elif (dtn not in SUPPORTED_FORMATS and not dtn.startswith('str') - and not dtn.startswith('unicode')): - arr = _("%s arrays") % data.dtype.name - self.error(_("%s are currently not supported") % arr) - return False - - self.layout = QGridLayout() - self.setLayout(self.layout) - if title: - title = to_text_string(title) + " - " + _("NumPy object array") - else: - title = _("Array editor") - if readonly: - title += ' (' + _('read only') + ')' - self.setWindowTitle(title) - - # ---- Stack widget - self.stack = QStackedWidget(self) - if is_record_array: - for name in data.dtype.names: - self.stack.addWidget(ArrayEditorWidget(self, data[name], - readonly, xlabels, - ylabels)) - elif is_masked_array: - self.stack.addWidget(ArrayEditorWidget(self, data, readonly, - xlabels, ylabels)) - self.stack.addWidget(ArrayEditorWidget(self, data.data, readonly, - xlabels, ylabels)) - self.stack.addWidget(ArrayEditorWidget(self, data.mask, readonly, - xlabels, ylabels)) - elif data.ndim == 3: - # We create here the necessary widgets for current_dim_changed to - # work. The rest are created below. - # QSpinBox - self.index_spin = QSpinBox(self, keyboardTracking=False) - self.index_spin.valueChanged.connect(self.change_active_widget) - - # Labels - self.shape_label = QLabel() - self.slicing_label = QLabel() - - # Set the widget to display when launched - self.current_dim_changed(self.last_dim) - else: - self.stack.addWidget(ArrayEditorWidget(self, data, readonly, - xlabels, ylabels)) - - self.arraywidget = self.stack.currentWidget() - self.arraywidget.model.dataChanged.connect(self.save_and_close_enable) - self.stack.currentChanged.connect(self.current_widget_changed) - self.layout.addWidget(self.stack, 1, 0) - - # ---- Top row of buttons - btn_layout_top = None - if is_record_array or is_masked_array or data.ndim == 3: - btn_layout_top = QHBoxLayout() - - if is_record_array: - btn_layout_top.addWidget(QLabel(_("Record array fields:"))) - names = [] - for name in data.dtype.names: - field = data.dtype.fields[name] - text = name - if len(field) >= 3: - title = field[2] - if not is_text_string(title): - title = repr(title) - text += ' - '+title - names.append(text) - else: - names = [_('Masked data'), _('Data'), _('Mask')] - - if data.ndim == 3: - # QComboBox - names = [str(i) for i in range(3)] - ra_combo = QComboBox(self) - ra_combo.addItems(names) - ra_combo.currentIndexChanged.connect(self.current_dim_changed) - - # Adding the widgets to layout - label = QLabel(_("Axis:")) - btn_layout_top.addWidget(label) - btn_layout_top.addWidget(ra_combo) - btn_layout_top.addWidget(self.shape_label) - - label = QLabel(_("Index:")) - btn_layout_top.addWidget(label) - btn_layout_top.addWidget(self.index_spin) - - btn_layout_top.addWidget(self.slicing_label) - else: - ra_combo = QComboBox(self) - ra_combo.currentIndexChanged.connect(self.stack.setCurrentIndex) - ra_combo.addItems(names) - btn_layout_top.addWidget(ra_combo) - - if is_masked_array: - label = QLabel( - _("Warning: Changes are applied separately") - ) - label.setToolTip(_("For performance reasons, changes applied " - "to masked arrays won't be reflected in " - "array's data (and vice-versa).")) - btn_layout_top.addWidget(label) - - btn_layout_top.addStretch() - - # ---- Bottom row of buttons - btn_layout_bottom = QHBoxLayout() - - btn_format = QPushButton(_("Format")) - # disable format button for int type - btn_format.setEnabled(is_float(self.arraywidget.data.dtype)) - btn_layout_bottom.addWidget(btn_format) - btn_format.clicked.connect(lambda: self.arraywidget.change_format()) - - btn_resize = QPushButton(_("Resize")) - btn_layout_bottom.addWidget(btn_resize) - btn_resize.clicked.connect( - lambda: self.arraywidget.view.resize_to_contents()) - - self.bgcolor = QCheckBox(_('Background color')) - self.bgcolor.setEnabled(self.arraywidget.model.bgcolor_enabled) - self.bgcolor.setChecked(self.arraywidget.model.bgcolor_enabled) - self.bgcolor.stateChanged.connect( - lambda state: self.arraywidget.model.bgcolor(state)) - btn_layout_bottom.addWidget(self.bgcolor) - - btn_layout_bottom.addStretch() - - if not readonly: - self.btn_save_and_close = QPushButton(_('Save and Close')) - self.btn_save_and_close.setDisabled(True) - self.btn_save_and_close.clicked.connect(self.accept) - btn_layout_bottom.addWidget(self.btn_save_and_close) - - self.btn_close = QPushButton(_('Close')) - self.btn_close.setAutoDefault(True) - self.btn_close.setDefault(True) - self.btn_close.clicked.connect(self.reject) - btn_layout_bottom.addWidget(self.btn_close) - - # ---- Final layout - btn_layout_bottom.setContentsMargins(4, 4, 4, 4) - if btn_layout_top is not None: - btn_layout_top.setContentsMargins(4, 4, 4, 4) - self.layout.addLayout(btn_layout_top, 2, 0) - self.layout.addLayout(btn_layout_bottom, 3, 0) - else: - self.layout.addLayout(btn_layout_bottom, 2, 0) - - # Set minimum size - self.setMinimumSize(500, 300) - - # Make the dialog act as a window - self.setWindowFlags(Qt.Window) - - return True - - @Slot(QModelIndex, QModelIndex) - def save_and_close_enable(self, left_top, bottom_right): - """Handle the data change event to enable the save and close button.""" - if self.btn_save_and_close: - self.btn_save_and_close.setEnabled(True) - self.btn_save_and_close.setAutoDefault(True) - self.btn_save_and_close.setDefault(True) - - def current_widget_changed(self, index): - self.arraywidget = self.stack.widget(index) - self.arraywidget.model.dataChanged.connect(self.save_and_close_enable) - self.bgcolor.setChecked(self.arraywidget.model.bgcolor_enabled) - - def change_active_widget(self, index): - """ - This is implemented for handling negative values in index for - 3d arrays, to give the same behavior as slicing - """ - string_index = [':']*3 - string_index[self.last_dim] = '%i' - self.slicing_label.setText((r"Slicing: [" + ", ".join(string_index) + - "]") % index) - if index < 0: - data_index = self.data.shape[self.last_dim] + index - else: - data_index = index - slice_index = [slice(None)]*3 - slice_index[self.last_dim] = data_index - - stack_index = self.dim_indexes[self.last_dim].get(data_index) - if stack_index is None: - stack_index = self.stack.count() - try: - self.stack.addWidget(ArrayEditorWidget( - self, self.data[tuple(slice_index)])) - except IndexError: # Handle arrays of size 0 in one axis - self.stack.addWidget(ArrayEditorWidget(self, self.data)) - self.dim_indexes[self.last_dim][data_index] = stack_index - self.stack.update() - self.stack.setCurrentIndex(stack_index) - - def current_dim_changed(self, index): - """ - This change the active axis the array editor is plotting over - in 3D - """ - self.last_dim = index - string_size = ['%i']*3 - string_size[index] = '%i' - self.shape_label.setText(('Shape: (' + ', '.join(string_size) + - ') ') % self.data.shape) - if self.index_spin.value() != 0: - self.index_spin.setValue(0) - else: - # this is done since if the value is currently 0 it does not emit - # currentIndexChanged(int) - self.change_active_widget(0) - self.index_spin.setRange(-self.data.shape[index], - self.data.shape[index]-1) - - @Slot() - def accept(self): - """Reimplement Qt method.""" - try: - for index in range(self.stack.count()): - self.stack.widget(index).accept_changes() - QDialog.accept(self) - except RuntimeError: - # Sometimes under CI testing the object the following error appears - # RuntimeError: wrapped C/C++ object has been deleted - pass - - def get_value(self): - """Return modified array -- this is *not* a copy""" - # It is important to avoid accessing Qt C++ object as it has probably - # already been destroyed, due to the Qt.WA_DeleteOnClose attribute - return self.data - - def error(self, message): - """An error occurred, closing the dialog box""" - QMessageBox.critical(self, _("Array editor"), message) - self.setAttribute(Qt.WA_DeleteOnClose) - self.reject() - - @Slot() - def reject(self): - """Reimplement Qt method""" - if self.arraywidget is not None: - for index in range(self.stack.count()): - self.stack.widget(index).reject_changes() - QDialog.reject(self) +# -*- coding: utf-8 -*- +# +# Copyright © Spyder Project Contributors +# Licensed under the terms of the MIT License +# (see spyder/__init__.py for details) + +""" +NumPy Array Editor Dialog based on Qt +""" + +# pylint: disable=C0103 +# pylint: disable=R0903 +# pylint: disable=R0911 +# pylint: disable=R0201 + +# Standard library imports +from __future__ import print_function + +# Third party imports +from qtpy.compat import from_qvariant, to_qvariant +from qtpy.QtCore import (QAbstractTableModel, QItemSelection, QLocale, + QItemSelectionRange, QModelIndex, Qt, Slot) +from qtpy.QtGui import QColor, QCursor, QDoubleValidator, QKeySequence +from qtpy.QtWidgets import (QAbstractItemDelegate, QApplication, QCheckBox, + QComboBox, QDialog, QGridLayout, QHBoxLayout, + QInputDialog, QItemDelegate, QLabel, QLineEdit, + QMenu, QMessageBox, QPushButton, QSpinBox, + QStackedWidget, QTableView, QVBoxLayout, + QWidget) +from spyder_kernels.utils.nsview import value_to_display +from spyder_kernels.utils.lazymodules import numpy as np + +# Local imports +from spyder.config.base import _ +from spyder.config.fonts import DEFAULT_SMALL_DELTA +from spyder.config.gui import get_font +from spyder.config.manager import CONF +from spyder.py3compat import (io, is_binary_string, is_string, + is_text_string, PY3, to_binary_string, + to_text_string) +from spyder.utils.icon_manager import ima +from spyder.utils.qthelpers import add_actions, create_action, keybinding +from spyder.plugins.variableexplorer.widgets.basedialog import BaseDialog + +# Note: string and unicode data types will be formatted with '%s' (see below) +SUPPORTED_FORMATS = { + 'single': '%.6g', + 'double': '%.6g', + 'float_': '%.6g', + 'longfloat': '%.6g', + 'float16': '%.6g', + 'float32': '%.6g', + 'float64': '%.6g', + 'float96': '%.6g', + 'float128': '%.6g', + 'csingle': '%r', + 'complex_': '%r', + 'clongfloat': '%r', + 'complex64': '%r', + 'complex128': '%r', + 'complex192': '%r', + 'complex256': '%r', + 'byte': '%d', + 'bytes8': '%s', + 'short': '%d', + 'intc': '%d', + 'int_': '%d', + 'longlong': '%d', + 'intp': '%d', + 'int8': '%d', + 'int16': '%d', + 'int32': '%d', + 'int64': '%d', + 'ubyte': '%d', + 'ushort': '%d', + 'uintc': '%d', + 'uint': '%d', + 'ulonglong': '%d', + 'uintp': '%d', + 'uint8': '%d', + 'uint16': '%d', + 'uint32': '%d', + 'uint64': '%d', + 'bool_': '%r', + 'bool8': '%r', + 'bool': '%r', +} + + +LARGE_SIZE = 5e5 +LARGE_NROWS = 1e5 +LARGE_COLS = 60 + + +#============================================================================== +# Utility functions +#============================================================================== +def is_float(dtype): + """Return True if datatype dtype is a float kind""" + return ('float' in dtype.name) or dtype.name in ['single', 'double'] + + +def is_number(dtype): + """Return True is datatype dtype is a number kind""" + return is_float(dtype) or ('int' in dtype.name) or ('long' in dtype.name) \ + or ('short' in dtype.name) + + +def get_idx_rect(index_list): + """Extract the boundaries from a list of indexes""" + rows, cols = list(zip(*[(i.row(), i.column()) for i in index_list])) + return ( min(rows), max(rows), min(cols), max(cols) ) + + +#============================================================================== +# Main classes +#============================================================================== +class ArrayModel(QAbstractTableModel): + """Array Editor Table Model""" + + ROWS_TO_LOAD = 500 + COLS_TO_LOAD = 40 + + def __init__(self, data, format="%.6g", xlabels=None, ylabels=None, + readonly=False, parent=None): + QAbstractTableModel.__init__(self) + + self.dialog = parent + self.changes = {} + self.xlabels = xlabels + self.ylabels = ylabels + self.readonly = readonly + self.test_array = np.array([0], dtype=data.dtype) + + # for complex numbers, shading will be based on absolute value + # but for all other types it will be the real part + if data.dtype in (np.complex64, np.complex128): + self.color_func = np.abs + else: + self.color_func = np.real + + # Backgroundcolor settings + huerange = [.66, .99] # Hue + self.sat = .7 # Saturation + self.val = 1. # Value + self.alp = .6 # Alpha-channel + + self._data = data + self._format = format + + self.total_rows = self._data.shape[0] + self.total_cols = self._data.shape[1] + size = self.total_rows * self.total_cols + + if not self._data.dtype.name == 'object': + try: + self.vmin = np.nanmin(self.color_func(data)) + self.vmax = np.nanmax(self.color_func(data)) + if self.vmax == self.vmin: + self.vmin -= 1 + self.hue0 = huerange[0] + self.dhue = huerange[1]-huerange[0] + self.bgcolor_enabled = True + except (AttributeError, TypeError, ValueError): + self.vmin = None + self.vmax = None + self.hue0 = None + self.dhue = None + self.bgcolor_enabled = False + + # Array with infinite values cannot display background colors and + # crashes. See: spyder-ide/spyder#8093 + self.has_inf = False + if data.dtype.kind in ['f', 'c']: + self.has_inf = np.any(np.isinf(data)) + + # Deactivate coloring for object arrays or arrays with inf values + if self._data.dtype.name == 'object' or self.has_inf: + self.bgcolor_enabled = False + + # Use paging when the total size, number of rows or number of + # columns is too large + if size > LARGE_SIZE: + self.rows_loaded = self.ROWS_TO_LOAD + self.cols_loaded = self.COLS_TO_LOAD + else: + if self.total_rows > LARGE_NROWS: + self.rows_loaded = self.ROWS_TO_LOAD + else: + self.rows_loaded = self.total_rows + if self.total_cols > LARGE_COLS: + self.cols_loaded = self.COLS_TO_LOAD + else: + self.cols_loaded = self.total_cols + + def get_format(self): + """Return current format""" + # Avoid accessing the private attribute _format from outside + return self._format + + def get_data(self): + """Return data""" + return self._data + + def set_format(self, format): + """Change display format""" + self._format = format + self.reset() + + def columnCount(self, qindex=QModelIndex()): + """Array column number""" + if self.total_cols <= self.cols_loaded: + return self.total_cols + else: + return self.cols_loaded + + def rowCount(self, qindex=QModelIndex()): + """Array row number""" + if self.total_rows <= self.rows_loaded: + return self.total_rows + else: + return self.rows_loaded + + def can_fetch_more(self, rows=False, columns=False): + if rows: + if self.total_rows > self.rows_loaded: + return True + else: + return False + if columns: + if self.total_cols > self.cols_loaded: + return True + else: + return False + + def fetch_more(self, rows=False, columns=False): + if self.can_fetch_more(rows=rows): + reminder = self.total_rows - self.rows_loaded + items_to_fetch = min(reminder, self.ROWS_TO_LOAD) + self.beginInsertRows(QModelIndex(), self.rows_loaded, + self.rows_loaded + items_to_fetch - 1) + self.rows_loaded += items_to_fetch + self.endInsertRows() + if self.can_fetch_more(columns=columns): + reminder = self.total_cols - self.cols_loaded + items_to_fetch = min(reminder, self.COLS_TO_LOAD) + self.beginInsertColumns(QModelIndex(), self.cols_loaded, + self.cols_loaded + items_to_fetch - 1) + self.cols_loaded += items_to_fetch + self.endInsertColumns() + + def bgcolor(self, state): + """Toggle backgroundcolor""" + self.bgcolor_enabled = state > 0 + self.reset() + + def get_value(self, index): + i = index.row() + j = index.column() + if len(self._data.shape) == 1: + value = self._data[j] + else: + value = self._data[i, j] + return self.changes.get((i, j), value) + + def data(self, index, role=Qt.DisplayRole): + """Cell content.""" + if not index.isValid(): + return to_qvariant() + value = self.get_value(index) + dtn = self._data.dtype.name + + # Tranform binary string to unicode so they are displayed + # correctly + if is_binary_string(value): + try: + value = to_text_string(value, 'utf8') + except Exception: + pass + + # Handle roles + if role == Qt.DisplayRole: + if value is np.ma.masked: + return '' + else: + if dtn == 'object': + # We don't know what's inside an object array, so + # we can't trust value repr's here. + return value_to_display(value) + else: + try: + return to_qvariant(self._format % value) + except TypeError: + self.readonly = True + return repr(value) + elif role == Qt.TextAlignmentRole: + return to_qvariant(int(Qt.AlignCenter|Qt.AlignVCenter)) + elif (role == Qt.BackgroundColorRole and self.bgcolor_enabled + and value is not np.ma.masked and not self.has_inf): + try: + hue = (self.hue0 + + self.dhue * (float(self.vmax) - self.color_func(value)) + / (float(self.vmax) - self.vmin)) + hue = float(np.abs(hue)) + color = QColor.fromHsvF(hue, self.sat, self.val, self.alp) + return to_qvariant(color) + except (TypeError, ValueError): + return to_qvariant() + elif role == Qt.FontRole: + return to_qvariant(get_font(font_size_delta=DEFAULT_SMALL_DELTA)) + return to_qvariant() + + def setData(self, index, value, role=Qt.EditRole): + """Cell content change""" + if not index.isValid() or self.readonly: + return False + i = index.row() + j = index.column() + value = from_qvariant(value, str) + dtype = self._data.dtype.name + if dtype == "bool": + try: + val = bool(float(value)) + except ValueError: + val = value.lower() == "true" + elif dtype.startswith("string") or dtype.startswith("bytes"): + val = to_binary_string(value, 'utf8') + elif dtype.startswith("unicode") or dtype.startswith("str"): + val = to_text_string(value) + else: + if value.lower().startswith('e') or value.lower().endswith('e'): + return False + try: + val = complex(value) + if not val.imag: + val = val.real + except ValueError as e: + QMessageBox.critical(self.dialog, "Error", + "Value error: %s" % str(e)) + return False + try: + self.test_array[0] = val # will raise an Exception eventually + except OverflowError as e: + print("OverflowError: " + str(e)) # spyder: test-skip + QMessageBox.critical(self.dialog, "Error", + "Overflow error: %s" % str(e)) + return False + + # Add change to self.changes + self.changes[(i, j)] = val + self.dataChanged.emit(index, index) + + if not is_string(val): + val = self.color_func(val) + + if val > self.vmax: + self.vmax = val + + if val < self.vmin: + self.vmin = val + + return True + + def flags(self, index): + """Set editable flag""" + if not index.isValid(): + return Qt.ItemIsEnabled + return Qt.ItemFlags(int(QAbstractTableModel.flags(self, index) | + Qt.ItemIsEditable)) + + def headerData(self, section, orientation, role=Qt.DisplayRole): + """Set header data""" + if role != Qt.DisplayRole: + return to_qvariant() + labels = self.xlabels if orientation == Qt.Horizontal else self.ylabels + if labels is None: + return to_qvariant(int(section)) + else: + return to_qvariant(labels[section]) + + def reset(self): + self.beginResetModel() + self.endResetModel() + + +class ArrayDelegate(QItemDelegate): + """Array Editor Item Delegate""" + def __init__(self, dtype, parent=None): + QItemDelegate.__init__(self, parent) + self.dtype = dtype + + def createEditor(self, parent, option, index): + """Create editor widget""" + model = index.model() + value = model.get_value(index) + if type(value) == np.ndarray or model.readonly: + # The editor currently cannot properly handle this case + return + elif model._data.dtype.name == "bool": + value = not value + model.setData(index, to_qvariant(value)) + return + elif value is not np.ma.masked: + editor = QLineEdit(parent) + editor.setFont(get_font(font_size_delta=DEFAULT_SMALL_DELTA)) + editor.setAlignment(Qt.AlignCenter) + if is_number(self.dtype): + validator = QDoubleValidator(editor) + validator.setLocale(QLocale('C')) + editor.setValidator(validator) + editor.returnPressed.connect(self.commitAndCloseEditor) + return editor + + def commitAndCloseEditor(self): + """Commit and close editor""" + editor = self.sender() + # Avoid a segfault with PyQt5. Variable value won't be changed + # but at least Spyder won't crash. It seems generated by a bug in sip. + try: + self.commitData.emit(editor) + except AttributeError: + pass + self.closeEditor.emit(editor, QAbstractItemDelegate.NoHint) + + def setEditorData(self, editor, index): + """Set editor widget's data""" + text = from_qvariant(index.model().data(index, Qt.DisplayRole), str) + editor.setText(text) + + +#TODO: Implement "Paste" (from clipboard) feature +class ArrayView(QTableView): + """Array view class""" + def __init__(self, parent, model, dtype, shape): + QTableView.__init__(self, parent) + + self.setModel(model) + self.setItemDelegate(ArrayDelegate(dtype, self)) + total_width = 0 + for k in range(shape[1]): + total_width += self.columnWidth(k) + self.viewport().resize(min(total_width, 1024), self.height()) + self.shape = shape + self.menu = self.setup_menu() + CONF.config_shortcut( + self.copy, + context='variable_explorer', + name='copy', + parent=self) + self.horizontalScrollBar().valueChanged.connect( + self._load_more_columns) + self.verticalScrollBar().valueChanged.connect(self._load_more_rows) + + def _load_more_columns(self, value): + """Load more columns to display.""" + # Needed to avoid a NameError while fetching data when closing + # See spyder-ide/spyder#12034. + try: + self.load_more_data(value, columns=True) + except NameError: + pass + + def _load_more_rows(self, value): + """Load more rows to display.""" + # Needed to avoid a NameError while fetching data when closing + # See spyder-ide/spyder#12034. + try: + self.load_more_data(value, rows=True) + except NameError: + pass + + def load_more_data(self, value, rows=False, columns=False): + + try: + old_selection = self.selectionModel().selection() + old_rows_loaded = old_cols_loaded = None + + if rows and value == self.verticalScrollBar().maximum(): + old_rows_loaded = self.model().rows_loaded + self.model().fetch_more(rows=rows) + + if columns and value == self.horizontalScrollBar().maximum(): + old_cols_loaded = self.model().cols_loaded + self.model().fetch_more(columns=columns) + + if old_rows_loaded is not None or old_cols_loaded is not None: + # if we've changed anything, update selection + new_selection = QItemSelection() + for part in old_selection: + top = part.top() + bottom = part.bottom() + if (old_rows_loaded is not None and + top == 0 and bottom == (old_rows_loaded-1)): + # complete column selected (so expand it to match + # updated range) + bottom = self.model().rows_loaded-1 + left = part.left() + right = part.right() + if (old_cols_loaded is not None + and left == 0 and right == (old_cols_loaded-1)): + # compete row selected (so expand it to match updated + # range) + right = self.model().cols_loaded-1 + top_left = self.model().index(top, left) + bottom_right = self.model().index(bottom, right) + part = QItemSelectionRange(top_left, bottom_right) + new_selection.append(part) + self.selectionModel().select( + new_selection, self.selectionModel().ClearAndSelect) + except NameError: + # Needed to handle a NameError while fetching data when closing + # See isue 7880 + pass + + def resize_to_contents(self): + """Resize cells to contents""" + QApplication.setOverrideCursor(QCursor(Qt.WaitCursor)) + self.resizeColumnsToContents() + self.model().fetch_more(columns=True) + self.resizeColumnsToContents() + QApplication.restoreOverrideCursor() + + def setup_menu(self): + """Setup context menu""" + self.copy_action = create_action(self, _('Copy'), + shortcut=keybinding('Copy'), + icon=ima.icon('editcopy'), + triggered=self.copy, + context=Qt.WidgetShortcut) + menu = QMenu(self) + add_actions(menu, [self.copy_action, ]) + return menu + + def contextMenuEvent(self, event): + """Reimplement Qt method""" + self.menu.popup(event.globalPos()) + event.accept() + + def keyPressEvent(self, event): + """Reimplement Qt method""" + if event == QKeySequence.Copy: + self.copy() + else: + QTableView.keyPressEvent(self, event) + + def _sel_to_text(self, cell_range): + """Copy an array portion to a unicode string""" + if not cell_range: + return + row_min, row_max, col_min, col_max = get_idx_rect(cell_range) + if col_min == 0 and col_max == (self.model().cols_loaded-1): + # we've selected a whole column. It isn't possible to + # select only the first part of a column without loading more, + # so we can treat it as intentional and copy the whole thing + col_max = self.model().total_cols-1 + if row_min == 0 and row_max == (self.model().rows_loaded-1): + row_max = self.model().total_rows-1 + + _data = self.model().get_data() + if PY3: + output = io.BytesIO() + else: + output = io.StringIO() + try: + np.savetxt(output, _data[row_min:row_max+1, col_min:col_max+1], + delimiter='\t', fmt=self.model().get_format()) + except: + QMessageBox.warning(self, _("Warning"), + _("It was not possible to copy values for " + "this array")) + return + contents = output.getvalue().decode('utf-8') + output.close() + return contents + + @Slot() + def copy(self): + """Copy text to clipboard""" + cliptxt = self._sel_to_text( self.selectedIndexes() ) + clipboard = QApplication.clipboard() + clipboard.setText(cliptxt) + + +class ArrayEditorWidget(QWidget): + + def __init__(self, parent, data, readonly=False, + xlabels=None, ylabels=None): + QWidget.__init__(self, parent) + self.data = data + self.old_data_shape = None + if len(self.data.shape) == 1: + self.old_data_shape = self.data.shape + self.data.shape = (self.data.shape[0], 1) + elif len(self.data.shape) == 0: + self.old_data_shape = self.data.shape + self.data.shape = (1, 1) + + format = SUPPORTED_FORMATS.get(data.dtype.name, '%s') + self.model = ArrayModel(self.data, format=format, xlabels=xlabels, + ylabels=ylabels, readonly=readonly, parent=self) + self.view = ArrayView(self, self.model, data.dtype, data.shape) + + layout = QVBoxLayout() + layout.addWidget(self.view) + self.setLayout(layout) + + def accept_changes(self): + """Accept changes""" + for (i, j), value in list(self.model.changes.items()): + self.data[i, j] = value + if self.old_data_shape is not None: + self.data.shape = self.old_data_shape + + def reject_changes(self): + """Reject changes""" + if self.old_data_shape is not None: + self.data.shape = self.old_data_shape + + def change_format(self): + """Change display format""" + format, valid = QInputDialog.getText(self, _( 'Format'), + _( "Float formatting"), + QLineEdit.Normal, self.model.get_format()) + if valid: + format = str(format) + try: + format % 1.1 + except: + QMessageBox.critical(self, _("Error"), + _("Format (%s) is incorrect") % format) + return + self.model.set_format(format) + + +class ArrayEditor(BaseDialog): + """Array Editor Dialog""" + def __init__(self, parent=None): + super().__init__(parent) + + # 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.data = None + self.arraywidget = None + self.stack = None + self.layout = None + self.btn_save_and_close = None + self.btn_close = None + # Values for 3d array editor + self.dim_indexes = [{}, {}, {}] + self.last_dim = 0 # Adjust this for changing the startup dimension + + def setup_and_check(self, data, title='', readonly=False, + xlabels=None, ylabels=None): + """ + Setup ArrayEditor: + return False if data is not supported, True otherwise + """ + self.data = data + readonly = readonly or not self.data.flags.writeable + is_record_array = data.dtype.names is not None + is_masked_array = isinstance(data, np.ma.MaskedArray) + + if data.ndim > 3: + self.error(_("Arrays with more than 3 dimensions are not " + "supported")) + return False + if xlabels is not None and len(xlabels) != self.data.shape[1]: + self.error(_("The 'xlabels' argument length do no match array " + "column number")) + return False + if ylabels is not None and len(ylabels) != self.data.shape[0]: + self.error(_("The 'ylabels' argument length do no match array row " + "number")) + return False + if not is_record_array: + dtn = data.dtype.name + if dtn == 'object': + # If the array doesn't have shape, we can't display it + if data.shape == (): + self.error(_("Object arrays without shape are not " + "supported")) + return False + # We don't know what's inside these arrays, so we can't handle + # edits + self.readonly = readonly = True + elif (dtn not in SUPPORTED_FORMATS and not dtn.startswith('str') + and not dtn.startswith('unicode')): + arr = _("%s arrays") % data.dtype.name + self.error(_("%s are currently not supported") % arr) + return False + + self.layout = QGridLayout() + self.setLayout(self.layout) + if title: + title = to_text_string(title) + " - " + _("NumPy object array") + else: + title = _("Array editor") + if readonly: + title += ' (' + _('read only') + ')' + self.setWindowTitle(title) + + # ---- Stack widget + self.stack = QStackedWidget(self) + if is_record_array: + for name in data.dtype.names: + self.stack.addWidget(ArrayEditorWidget(self, data[name], + readonly, xlabels, + ylabels)) + elif is_masked_array: + self.stack.addWidget(ArrayEditorWidget(self, data, readonly, + xlabels, ylabels)) + self.stack.addWidget(ArrayEditorWidget(self, data.data, readonly, + xlabels, ylabels)) + self.stack.addWidget(ArrayEditorWidget(self, data.mask, readonly, + xlabels, ylabels)) + elif data.ndim == 3: + # We create here the necessary widgets for current_dim_changed to + # work. The rest are created below. + # QSpinBox + self.index_spin = QSpinBox(self, keyboardTracking=False) + self.index_spin.valueChanged.connect(self.change_active_widget) + + # Labels + self.shape_label = QLabel() + self.slicing_label = QLabel() + + # Set the widget to display when launched + self.current_dim_changed(self.last_dim) + else: + self.stack.addWidget(ArrayEditorWidget(self, data, readonly, + xlabels, ylabels)) + + self.arraywidget = self.stack.currentWidget() + self.arraywidget.model.dataChanged.connect(self.save_and_close_enable) + self.stack.currentChanged.connect(self.current_widget_changed) + self.layout.addWidget(self.stack, 1, 0) + + # ---- Top row of buttons + btn_layout_top = None + if is_record_array or is_masked_array or data.ndim == 3: + btn_layout_top = QHBoxLayout() + + if is_record_array: + btn_layout_top.addWidget(QLabel(_("Record array fields:"))) + names = [] + for name in data.dtype.names: + field = data.dtype.fields[name] + text = name + if len(field) >= 3: + title = field[2] + if not is_text_string(title): + title = repr(title) + text += ' - '+title + names.append(text) + else: + names = [_('Masked data'), _('Data'), _('Mask')] + + if data.ndim == 3: + # QComboBox + names = [str(i) for i in range(3)] + ra_combo = QComboBox(self) + ra_combo.addItems(names) + ra_combo.currentIndexChanged.connect(self.current_dim_changed) + + # Adding the widgets to layout + label = QLabel(_("Axis:")) + btn_layout_top.addWidget(label) + btn_layout_top.addWidget(ra_combo) + btn_layout_top.addWidget(self.shape_label) + + label = QLabel(_("Index:")) + btn_layout_top.addWidget(label) + btn_layout_top.addWidget(self.index_spin) + + btn_layout_top.addWidget(self.slicing_label) + else: + ra_combo = QComboBox(self) + ra_combo.currentIndexChanged.connect(self.stack.setCurrentIndex) + ra_combo.addItems(names) + btn_layout_top.addWidget(ra_combo) + + if is_masked_array: + label = QLabel( + _("Warning: Changes are applied separately") + ) + label.setToolTip(_("For performance reasons, changes applied " + "to masked arrays won't be reflected in " + "array's data (and vice-versa).")) + btn_layout_top.addWidget(label) + + btn_layout_top.addStretch() + + # ---- Bottom row of buttons + btn_layout_bottom = QHBoxLayout() + + btn_format = QPushButton(_("Format")) + # disable format button for int type + btn_format.setEnabled(is_float(self.arraywidget.data.dtype)) + btn_layout_bottom.addWidget(btn_format) + btn_format.clicked.connect(lambda: self.arraywidget.change_format()) + + btn_resize = QPushButton(_("Resize")) + btn_layout_bottom.addWidget(btn_resize) + btn_resize.clicked.connect( + lambda: self.arraywidget.view.resize_to_contents()) + + self.bgcolor = QCheckBox(_('Background color')) + self.bgcolor.setEnabled(self.arraywidget.model.bgcolor_enabled) + self.bgcolor.setChecked(self.arraywidget.model.bgcolor_enabled) + self.bgcolor.stateChanged.connect( + lambda state: self.arraywidget.model.bgcolor(state)) + btn_layout_bottom.addWidget(self.bgcolor) + + btn_layout_bottom.addStretch() + + if not readonly: + self.btn_save_and_close = QPushButton(_('Save and Close')) + self.btn_save_and_close.setDisabled(True) + self.btn_save_and_close.clicked.connect(self.accept) + btn_layout_bottom.addWidget(self.btn_save_and_close) + + self.btn_close = QPushButton(_('Close')) + self.btn_close.setAutoDefault(True) + self.btn_close.setDefault(True) + self.btn_close.clicked.connect(self.reject) + btn_layout_bottom.addWidget(self.btn_close) + + # ---- Final layout + btn_layout_bottom.setContentsMargins(4, 4, 4, 4) + if btn_layout_top is not None: + btn_layout_top.setContentsMargins(4, 4, 4, 4) + self.layout.addLayout(btn_layout_top, 2, 0) + self.layout.addLayout(btn_layout_bottom, 3, 0) + else: + self.layout.addLayout(btn_layout_bottom, 2, 0) + + # Set minimum size + self.setMinimumSize(500, 300) + + # Make the dialog act as a window + self.setWindowFlags(Qt.Window) + + return True + + @Slot(QModelIndex, QModelIndex) + def save_and_close_enable(self, left_top, bottom_right): + """Handle the data change event to enable the save and close button.""" + if self.btn_save_and_close: + self.btn_save_and_close.setEnabled(True) + self.btn_save_and_close.setAutoDefault(True) + self.btn_save_and_close.setDefault(True) + + def current_widget_changed(self, index): + self.arraywidget = self.stack.widget(index) + self.arraywidget.model.dataChanged.connect(self.save_and_close_enable) + self.bgcolor.setChecked(self.arraywidget.model.bgcolor_enabled) + + def change_active_widget(self, index): + """ + This is implemented for handling negative values in index for + 3d arrays, to give the same behavior as slicing + """ + string_index = [':']*3 + string_index[self.last_dim] = '%i' + self.slicing_label.setText((r"Slicing: [" + ", ".join(string_index) + + "]") % index) + if index < 0: + data_index = self.data.shape[self.last_dim] + index + else: + data_index = index + slice_index = [slice(None)]*3 + slice_index[self.last_dim] = data_index + + stack_index = self.dim_indexes[self.last_dim].get(data_index) + if stack_index is None: + stack_index = self.stack.count() + try: + self.stack.addWidget(ArrayEditorWidget( + self, self.data[tuple(slice_index)])) + except IndexError: # Handle arrays of size 0 in one axis + self.stack.addWidget(ArrayEditorWidget(self, self.data)) + self.dim_indexes[self.last_dim][data_index] = stack_index + self.stack.update() + self.stack.setCurrentIndex(stack_index) + + def current_dim_changed(self, index): + """ + This change the active axis the array editor is plotting over + in 3D + """ + self.last_dim = index + string_size = ['%i']*3 + string_size[index] = '%i' + self.shape_label.setText(('Shape: (' + ', '.join(string_size) + + ') ') % self.data.shape) + if self.index_spin.value() != 0: + self.index_spin.setValue(0) + else: + # this is done since if the value is currently 0 it does not emit + # currentIndexChanged(int) + self.change_active_widget(0) + self.index_spin.setRange(-self.data.shape[index], + self.data.shape[index]-1) + + @Slot() + def accept(self): + """Reimplement Qt method.""" + try: + for index in range(self.stack.count()): + self.stack.widget(index).accept_changes() + QDialog.accept(self) + except RuntimeError: + # Sometimes under CI testing the object the following error appears + # RuntimeError: wrapped C/C++ object has been deleted + pass + + def get_value(self): + """Return modified array -- this is *not* a copy""" + # It is important to avoid accessing Qt C++ object as it has probably + # already been destroyed, due to the Qt.WA_DeleteOnClose attribute + return self.data + + def error(self, message): + """An error occurred, closing the dialog box""" + QMessageBox.critical(self, _("Array editor"), message) + self.setAttribute(Qt.WA_DeleteOnClose) + self.reject() + + @Slot() + def reject(self): + """Reimplement Qt method""" + if self.arraywidget is not None: + for index in range(self.stack.count()): + self.stack.widget(index).reject_changes() + QDialog.reject(self) diff --git a/spyder/plugins/variableexplorer/widgets/dataframeeditor.py b/spyder/plugins/variableexplorer/widgets/dataframeeditor.py index 911689d19b6..e74751dd105 100644 --- a/spyder/plugins/variableexplorer/widgets/dataframeeditor.py +++ b/spyder/plugins/variableexplorer/widgets/dataframeeditor.py @@ -1,1430 +1,1430 @@ -# -*- coding: utf-8 -*- -# ----------------------------------------------------------------------------- -# Copyright (c) 2011-2012 Lambda Foundry, Inc. and PyData Development Team -# Copyright (c) 2013 Jev Kuznetsov and contributors -# Copyright (c) 2014-2015 Scott Hansen -# Copyright (c) 2014-2016 Yuri D'Elia "wave++" -# Copyright (c) 2014- Spyder Project Contributors -# -# Components of gtabview originally distributed under the MIT (Expat) license. -# This file as a whole distributed under the terms of the New BSD License -# (BSD 3-clause; see NOTICE.txt in the Spyder root directory for details). -# ----------------------------------------------------------------------------- - -""" -Pandas DataFrame Editor Dialog. - -DataFrameModel is based on the class ArrayModel from array editor -and the class DataFrameModel from the pandas project. -Present in pandas.sandbox.qtpandas in v0.13.1. - -DataFrameHeaderModel and DataFrameLevelModel are based on the classes -Header4ExtModel and Level4ExtModel from the gtabview project. -DataFrameModel is based on the classes ExtDataModel and ExtFrameModel, and -DataFrameEditor is based on gtExtTableView from the same project. - -DataFrameModel originally based on pandas/sandbox/qtpandas.py of the -`pandas project `_. -The current version is qtpandas/models/DataFrameModel.py of the -`QtPandas project `_. - -Components of gtabview from gtabview/viewer.py and gtabview/models.py of the -`gtabview project `_. -""" - -# Standard library imports - -# Third party imports -from qtpy.compat import from_qvariant, to_qvariant -from qtpy.QtCore import (QAbstractTableModel, QModelIndex, Qt, Signal, Slot, - QItemSelectionModel, QEvent) -from qtpy.QtGui import QColor, QCursor -from qtpy.QtWidgets import (QApplication, QCheckBox, QDialog, QGridLayout, - QHBoxLayout, QInputDialog, QLineEdit, QMenu, - QMessageBox, QPushButton, QTableView, - QScrollBar, QTableWidget, QFrame, - QItemDelegate) -from spyder_kernels.utils.lazymodules import numpy as np, pandas as pd - -# Local imports -from spyder.api.config.mixins import SpyderConfigurationAccessor -from spyder.config.base import _ -from spyder.config.fonts import DEFAULT_SMALL_DELTA -from spyder.config.gui import get_font -from spyder.py3compat import (io, is_text_string, is_type_text_string, PY2, - to_text_string, perf_counter) -from spyder.utils.icon_manager import ima -from spyder.utils.qthelpers import (add_actions, create_action, - keybinding, qapplication) -from spyder.plugins.variableexplorer.widgets.arrayeditor import get_idx_rect -from spyder.plugins.variableexplorer.widgets.basedialog import BaseDialog - -# Supported Numbers and complex numbers -REAL_NUMBER_TYPES = (float, int, np.int64, np.int32) -COMPLEX_NUMBER_TYPES = (complex, np.complex64, np.complex128) -# Used to convert bool intrance to false since bool('False') will return True -_bool_false = ['false', 'f', '0', '0.', '0.0', ' '] - -# Default format for data frames with floats -DEFAULT_FORMAT = '%.6g' - -# Limit at which dataframe is considered so large that it is loaded on demand -LARGE_SIZE = 5e5 -LARGE_NROWS = 1e5 -LARGE_COLS = 60 -ROWS_TO_LOAD = 500 -COLS_TO_LOAD = 40 - -# Background colours -BACKGROUND_NUMBER_MINHUE = 0.66 # hue for largest number -BACKGROUND_NUMBER_HUERANGE = 0.33 # (hue for smallest) minus (hue for largest) -BACKGROUND_NUMBER_SATURATION = 0.7 -BACKGROUND_NUMBER_VALUE = 1.0 -BACKGROUND_NUMBER_ALPHA = 0.6 -BACKGROUND_NONNUMBER_COLOR = Qt.lightGray -BACKGROUND_INDEX_ALPHA = 0.8 -BACKGROUND_STRING_ALPHA = 0.05 -BACKGROUND_MISC_ALPHA = 0.3 - - -def bool_false_check(value): - """ - Used to convert bool entrance to false. - - Needed since any string in bool('') will return True. - """ - if value.lower() in _bool_false: - value = '' - return value - - -def global_max(col_vals, index): - """Returns the global maximum and minimum.""" - col_vals_without_None = [x for x in col_vals if x is not None] - max_col, min_col = zip(*col_vals_without_None) - return max(max_col), min(min_col) - - -class DataFrameModel(QAbstractTableModel): - """ - DataFrame Table Model. - - Partly based in ExtDataModel and ExtFrameModel classes - of the gtabview project. - - For more information please see: - https://github.com/wavexx/gtabview/blob/master/gtabview/models.py - """ - - def __init__(self, dataFrame, format=DEFAULT_FORMAT, parent=None): - QAbstractTableModel.__init__(self) - self.dialog = parent - self.df = dataFrame - self.df_columns_list = None - self.df_index_list = None - self._format = format - self.complex_intran = None - self.display_error_idxs = [] - - self.total_rows = self.df.shape[0] - self.total_cols = self.df.shape[1] - size = self.total_rows * self.total_cols - - self.max_min_col = None - if size < LARGE_SIZE: - self.max_min_col_update() - self.colum_avg_enabled = True - self.bgcolor_enabled = True - self.colum_avg(1) - else: - self.colum_avg_enabled = False - self.bgcolor_enabled = False - self.colum_avg(0) - - # Use paging when the total size, number of rows or number of - # columns is too large - if size > LARGE_SIZE: - self.rows_loaded = ROWS_TO_LOAD - self.cols_loaded = COLS_TO_LOAD - else: - if self.total_rows > LARGE_NROWS: - self.rows_loaded = ROWS_TO_LOAD - else: - self.rows_loaded = self.total_rows - if self.total_cols > LARGE_COLS: - self.cols_loaded = COLS_TO_LOAD - else: - self.cols_loaded = self.total_cols - - def _axis(self, axis): - """ - Return the corresponding labels taking into account the axis. - - The axis could be horizontal (0) or vertical (1). - """ - return self.df.columns if axis == 0 else self.df.index - - def _axis_list(self, axis): - """ - Return the corresponding labels as a list taking into account the axis. - - The axis could be horizontal (0) or vertical (1). - """ - if axis == 0: - if self.df_columns_list is None: - self.df_columns_list = self.df.columns.tolist() - return self.df_columns_list - else: - if self.df_index_list is None: - self.df_index_list = self.df.index.tolist() - return self.df_index_list - - def _axis_levels(self, axis): - """ - Return the number of levels in the labels taking into account the axis. - - Get the number of levels for the columns (0) or rows (1). - """ - ax = self._axis(axis) - return 1 if not hasattr(ax, 'levels') else len(ax.levels) - - @property - def shape(self): - """Return the shape of the dataframe.""" - return self.df.shape - - @property - def header_shape(self): - """Return the levels for the columns and rows of the dataframe.""" - return (self._axis_levels(0), self._axis_levels(1)) - - @property - def chunk_size(self): - """Return the max value of the dimensions of the dataframe.""" - return max(*self.shape()) - - def header(self, axis, x, level=0): - """ - Return the values of the labels for the header of columns or rows. - - The value corresponds to the header of column or row x in the - given level. - """ - ax = self._axis(axis) - if not hasattr(ax, 'levels'): - ax = self._axis_list(axis) - return ax[x] - else: - return ax.values[x][level] - - def name(self, axis, level): - """Return the labels of the levels if any.""" - ax = self._axis(axis) - if hasattr(ax, 'levels'): - return ax.names[level] - if ax.name: - return ax.name - - def max_min_col_update(self): - """ - Determines the maximum and minimum number in each column. - - The result is a list whose k-th entry is [vmax, vmin], where vmax and - vmin denote the maximum and minimum of the k-th column (ignoring NaN). - This list is stored in self.max_min_col. - - If the k-th column has a non-numerical dtype, then the k-th entry - is set to None. If the dtype is complex, then compute the maximum and - minimum of the absolute values. If vmax equals vmin, then vmin is - decreased by one. - """ - if self.df.shape[0] == 0: # If no rows to compute max/min then return - return - self.max_min_col = [] - for __, col in self.df.iteritems(): - # This is necessary to catch an error in Pandas when computing - # the maximum of a column. - # Fixes spyder-ide/spyder#17145 - try: - if col.dtype in REAL_NUMBER_TYPES + COMPLEX_NUMBER_TYPES: - if col.dtype in REAL_NUMBER_TYPES: - vmax = col.max(skipna=True) - vmin = col.min(skipna=True) - else: - vmax = col.abs().max(skipna=True) - vmin = col.abs().min(skipna=True) - if vmax != vmin: - max_min = [vmax, vmin] - else: - max_min = [vmax, vmin - 1] - else: - max_min = None - except TypeError: - max_min = None - self.max_min_col.append(max_min) - - def get_format(self): - """Return current format""" - # Avoid accessing the private attribute _format from outside - return self._format - - def set_format(self, format): - """Change display format""" - self._format = format - self.reset() - - def bgcolor(self, state): - """Toggle backgroundcolor""" - self.bgcolor_enabled = state > 0 - self.reset() - - def colum_avg(self, state): - """Toggle backgroundcolor""" - self.colum_avg_enabled = state > 0 - if self.colum_avg_enabled: - self.return_max = lambda col_vals, index: col_vals[index] - else: - self.return_max = global_max - self.reset() - - def get_bgcolor(self, index): - """Background color depending on value.""" - column = index.column() - - if not self.bgcolor_enabled: - return - - value = self.get_value(index.row(), column) - if self.max_min_col[column] is None or pd.isna(value): - color = QColor(BACKGROUND_NONNUMBER_COLOR) - if is_text_string(value): - color.setAlphaF(BACKGROUND_STRING_ALPHA) - else: - color.setAlphaF(BACKGROUND_MISC_ALPHA) - else: - if isinstance(value, COMPLEX_NUMBER_TYPES): - color_func = abs - else: - color_func = float - vmax, vmin = self.return_max(self.max_min_col, column) - - # This is necessary to catch an error in Pandas when computing - # the difference between the max and min of a column. - # Fixes spyder-ide/spyder#18005 - try: - if vmax - vmin == 0: - vmax_vmin_diff = 1.0 - else: - vmax_vmin_diff = vmax - vmin - except TypeError: - return - - hue = (BACKGROUND_NUMBER_MINHUE + BACKGROUND_NUMBER_HUERANGE * - (vmax - color_func(value)) / (vmax_vmin_diff)) - hue = float(abs(hue)) - if hue > 1: - hue = 1 - color = QColor.fromHsvF(hue, BACKGROUND_NUMBER_SATURATION, - BACKGROUND_NUMBER_VALUE, - BACKGROUND_NUMBER_ALPHA) - - return color - - def get_value(self, row, column): - """Return the value of the DataFrame.""" - # To increase the performance iat is used but that requires error - # handling, so fallback uses iloc - try: - value = self.df.iat[row, column] - except pd._libs.tslib.OutOfBoundsDatetime: - value = self.df.iloc[:, column].astype(str).iat[row] - except: - value = self.df.iloc[row, column] - return value - - def data(self, index, role=Qt.DisplayRole): - """Cell content""" - if not index.isValid(): - return to_qvariant() - if role == Qt.DisplayRole or role == Qt.EditRole: - column = index.column() - row = index.row() - value = self.get_value(row, column) - if isinstance(value, float): - try: - return to_qvariant(self._format % value) - except (ValueError, TypeError): - # may happen if format = '%d' and value = NaN; - # see spyder-ide/spyder#4139. - return to_qvariant(DEFAULT_FORMAT % value) - elif is_type_text_string(value): - # Don't perform any conversion on strings - # because it leads to differences between - # the data present in the dataframe and - # what is shown by Spyder - return value - else: - try: - return to_qvariant(to_text_string(value)) - except Exception: - self.display_error_idxs.append(index) - return u'Display Error!' - elif role == Qt.BackgroundColorRole: - return to_qvariant(self.get_bgcolor(index)) - elif role == Qt.FontRole: - return to_qvariant(get_font(font_size_delta=DEFAULT_SMALL_DELTA)) - elif role == Qt.ToolTipRole: - if index in self.display_error_idxs: - return _("It is not possible to display this value because\n" - "an error ocurred while trying to do it") - return to_qvariant() - - def recalculate_index(self): - """Recalcuate index information.""" - self.df_index_list = self.df.index.tolist() - - def sort(self, column, order=Qt.AscendingOrder): - """Overriding sort method""" - if self.complex_intran is not None: - if self.complex_intran.any(axis=0).iloc[column]: - QMessageBox.critical(self.dialog, "Error", - "TypeError error: no ordering " - "relation is defined for complex numbers") - return False - try: - ascending = order == Qt.AscendingOrder - if column >= 0: - try: - self.df.sort_values(by=self.df.columns[column], - ascending=ascending, inplace=True, - kind='mergesort') - except AttributeError: - # for pandas version < 0.17 - self.df.sort(columns=self.df.columns[column], - ascending=ascending, inplace=True, - kind='mergesort') - except ValueError as e: - # Not possible to sort on duplicate columns - # See spyder-ide/spyder#5225. - QMessageBox.critical(self.dialog, "Error", - "ValueError: %s" % to_text_string(e)) - except SystemError as e: - # Not possible to sort on category dtypes - # See spyder-ide/spyder#5361. - QMessageBox.critical(self.dialog, "Error", - "SystemError: %s" % to_text_string(e)) - else: - # Update index list - self.recalculate_index() - # To sort by index - self.df.sort_index(inplace=True, ascending=ascending) - except TypeError as e: - QMessageBox.critical(self.dialog, "Error", - "TypeError error: %s" % str(e)) - return False - - self.reset() - return True - - def flags(self, index): - """Set flags""" - return Qt.ItemFlags(int(QAbstractTableModel.flags(self, index) | - Qt.ItemIsEditable)) - - def setData(self, index, value, role=Qt.EditRole, change_type=None): - """Cell content change""" - column = index.column() - row = index.row() - - if index in self.display_error_idxs: - return False - if change_type is not None: - try: - value = self.data(index, role=Qt.DisplayRole) - val = from_qvariant(value, str) - if change_type is bool: - val = bool_false_check(val) - self.df.iloc[row, column] = change_type(val) - except ValueError: - self.df.iloc[row, column] = change_type('0') - else: - val = from_qvariant(value, str) - current_value = self.get_value(row, column) - if isinstance(current_value, (bool, np.bool_)): - val = bool_false_check(val) - supported_types = (bool, np.bool_) + REAL_NUMBER_TYPES - if (isinstance(current_value, supported_types) or - is_text_string(current_value)): - try: - self.df.iloc[row, column] = current_value.__class__(val) - except (ValueError, OverflowError) as e: - QMessageBox.critical(self.dialog, "Error", - str(type(e).__name__) + ": " + str(e)) - return False - else: - QMessageBox.critical(self.dialog, "Error", - "Editing dtype {0!s} not yet supported." - .format(type(current_value).__name__)) - return False - self.max_min_col_update() - self.dataChanged.emit(index, index) - return True - - def get_data(self): - """Return data""" - return self.df - - def rowCount(self, index=QModelIndex()): - """DataFrame row number""" - # Avoid a "Qt exception in virtual methods" generated in our - # tests on Windows/Python 3.7 - # See spyder-ide/spyder#8910. - try: - if self.total_rows <= self.rows_loaded: - return self.total_rows - else: - return self.rows_loaded - except AttributeError: - return 0 - - def fetch_more(self, rows=False, columns=False): - """Get more columns and/or rows.""" - if rows and self.total_rows > self.rows_loaded: - reminder = self.total_rows - self.rows_loaded - items_to_fetch = min(reminder, ROWS_TO_LOAD) - self.beginInsertRows(QModelIndex(), self.rows_loaded, - self.rows_loaded + items_to_fetch - 1) - self.rows_loaded += items_to_fetch - self.endInsertRows() - if columns and self.total_cols > self.cols_loaded: - reminder = self.total_cols - self.cols_loaded - items_to_fetch = min(reminder, COLS_TO_LOAD) - self.beginInsertColumns(QModelIndex(), self.cols_loaded, - self.cols_loaded + items_to_fetch - 1) - self.cols_loaded += items_to_fetch - self.endInsertColumns() - - def columnCount(self, index=QModelIndex()): - """DataFrame column number""" - # Avoid a "Qt exception in virtual methods" generated in our - # tests on Windows/Python 3.7 - # See spyder-ide/spyder#8910. - try: - # This is done to implement series - if len(self.df.shape) == 1: - return 2 - elif self.total_cols <= self.cols_loaded: - return self.total_cols - else: - return self.cols_loaded - except AttributeError: - return 0 - - def reset(self): - self.beginResetModel() - self.endResetModel() - - -class DataFrameView(QTableView, SpyderConfigurationAccessor): - """ - Data Frame view class. - - Signals - ------- - sig_sort_by_column(): Raised after more columns are fetched. - sig_fetch_more_rows(): Raised after more rows are fetched. - """ - sig_sort_by_column = Signal() - sig_fetch_more_columns = Signal() - sig_fetch_more_rows = Signal() - - CONF_SECTION = 'variable_explorer' - - def __init__(self, parent, model, header, hscroll, vscroll): - """Constructor.""" - QTableView.__init__(self, parent) - self.setModel(model) - self.setHorizontalScrollBar(hscroll) - self.setVerticalScrollBar(vscroll) - self.setHorizontalScrollMode(QTableView.ScrollPerPixel) - self.setVerticalScrollMode(QTableView.ScrollPerPixel) - - self.sort_old = [None] - self.header_class = header - self.header_class.sectionClicked.connect(self.sortByColumn) - self.menu = self.setup_menu() - self.config_shortcut(self.copy, 'copy', self) - self.horizontalScrollBar().valueChanged.connect( - self._load_more_columns) - self.verticalScrollBar().valueChanged.connect(self._load_more_rows) - - def _load_more_columns(self, value): - """Load more columns to display.""" - # Needed to avoid a NameError while fetching data when closing - # See spyder-ide/spyder#12034. - try: - self.load_more_data(value, columns=True) - except NameError: - pass - - def _load_more_rows(self, value): - """Load more rows to display.""" - # Needed to avoid a NameError while fetching data when closing - # See spyder-ide/spyder#12034. - try: - self.load_more_data(value, rows=True) - except NameError: - pass - - def load_more_data(self, value, rows=False, columns=False): - """Load more rows and columns to display.""" - try: - if rows and value == self.verticalScrollBar().maximum(): - self.model().fetch_more(rows=rows) - self.sig_fetch_more_rows.emit() - if columns and value == self.horizontalScrollBar().maximum(): - self.model().fetch_more(columns=columns) - self.sig_fetch_more_columns.emit() - - except NameError: - # Needed to handle a NameError while fetching data when closing - # See spyder-ide/spyder#7880. - pass - - def sortByColumn(self, index): - """Implement a column sort.""" - if self.sort_old == [None]: - self.header_class.setSortIndicatorShown(True) - sort_order = self.header_class.sortIndicatorOrder() - if not self.model().sort(index, sort_order): - if len(self.sort_old) != 2: - self.header_class.setSortIndicatorShown(False) - else: - self.header_class.setSortIndicator(self.sort_old[0], - self.sort_old[1]) - return - self.sort_old = [index, self.header_class.sortIndicatorOrder()] - self.sig_sort_by_column.emit() - - def contextMenuEvent(self, event): - """Reimplement Qt method.""" - self.menu.popup(event.globalPos()) - event.accept() - - def setup_menu(self): - """Setup context menu.""" - copy_action = create_action(self, _('Copy'), - shortcut=keybinding('Copy'), - icon=ima.icon('editcopy'), - triggered=self.copy, - context=Qt.WidgetShortcut) - functions = ((_("To bool"), bool), (_("To complex"), complex), - (_("To int"), int), (_("To float"), float), - (_("To str"), to_text_string)) - types_in_menu = [copy_action] - for name, func in functions: - def slot(): - self.change_type(func) - types_in_menu += [create_action(self, name, - triggered=slot, - context=Qt.WidgetShortcut)] - menu = QMenu(self) - add_actions(menu, types_in_menu) - return menu - - def change_type(self, func): - """A function that changes types of cells.""" - model = self.model() - index_list = self.selectedIndexes() - [model.setData(i, '', change_type=func) for i in index_list] - - @Slot() - def copy(self): - """Copy text to clipboard""" - if not self.selectedIndexes(): - return - (row_min, row_max, - col_min, col_max) = get_idx_rect(self.selectedIndexes()) - # Copy index and header too (equal True). - # See spyder-ide/spyder#11096 - index = header = True - df = self.model().df - obj = df.iloc[slice(row_min, row_max + 1), - slice(col_min, col_max + 1)] - output = io.StringIO() - try: - obj.to_csv(output, sep='\t', index=index, header=header) - except UnicodeEncodeError: - # Needed to handle encoding errors in Python 2 - # See spyder-ide/spyder#4833 - QMessageBox.critical( - self, - _("Error"), - _("Text can't be copied.")) - if not PY2: - contents = output.getvalue() - else: - contents = output.getvalue().decode('utf-8') - output.close() - clipboard = QApplication.clipboard() - clipboard.setText(contents) - - -class DataFrameHeaderModel(QAbstractTableModel): - """ - This class is the model for the header or index of the DataFrameEditor. - - Taken from gtabview project (Header4ExtModel). - For more information please see: - https://github.com/wavexx/gtabview/blob/master/gtabview/viewer.py - """ - - COLUMN_INDEX = -1 # Makes reference to the index of the table. - - def __init__(self, model, axis, palette): - """ - Header constructor. - - The 'model' is the QAbstractTableModel of the dataframe, the 'axis' is - to acknowledge if is for the header (horizontal - 0) or for the - index (vertical - 1) and the palette is the set of colors to use. - """ - super(DataFrameHeaderModel, self).__init__() - self.model = model - self.axis = axis - self._palette = palette - self.total_rows = self.model.shape[0] - self.total_cols = self.model.shape[1] - size = self.total_rows * self.total_cols - - # Use paging when the total size, number of rows or number of - # columns is too large - if size > LARGE_SIZE: - self.rows_loaded = ROWS_TO_LOAD - self.cols_loaded = COLS_TO_LOAD - else: - if self.total_cols > LARGE_COLS: - self.cols_loaded = COLS_TO_LOAD - else: - self.cols_loaded = self.total_cols - if self.total_rows > LARGE_NROWS: - self.rows_loaded = ROWS_TO_LOAD - else: - self.rows_loaded = self.total_rows - - if self.axis == 0: - self.total_cols = self.model.shape[1] - self._shape = (self.model.header_shape[0], self.model.shape[1]) - else: - self.total_rows = self.model.shape[0] - self._shape = (self.model.shape[0], self.model.header_shape[1]) - - def rowCount(self, index=None): - """Get number of rows in the header.""" - if self.axis == 0: - return max(1, self._shape[0]) - else: - if self.total_rows <= self.rows_loaded: - return self.total_rows - else: - return self.rows_loaded - - def columnCount(self, index=QModelIndex()): - """DataFrame column number""" - if self.axis == 0: - if self.total_cols <= self.cols_loaded: - return self.total_cols - else: - return self.cols_loaded - else: - return max(1, self._shape[1]) - - def fetch_more(self, rows=False, columns=False): - """Get more columns or rows (based on axis).""" - if self.axis == 1 and self.total_rows > self.rows_loaded: - reminder = self.total_rows - self.rows_loaded - items_to_fetch = min(reminder, ROWS_TO_LOAD) - self.beginInsertRows(QModelIndex(), self.rows_loaded, - self.rows_loaded + items_to_fetch - 1) - self.rows_loaded += items_to_fetch - self.endInsertRows() - if self.axis == 0 and self.total_cols > self.cols_loaded: - reminder = self.total_cols - self.cols_loaded - items_to_fetch = min(reminder, COLS_TO_LOAD) - self.beginInsertColumns(QModelIndex(), self.cols_loaded, - self.cols_loaded + items_to_fetch - 1) - self.cols_loaded += items_to_fetch - self.endInsertColumns() - - def sort(self, column, order=Qt.AscendingOrder): - """Overriding sort method.""" - ascending = order == Qt.AscendingOrder - self.model.sort(self.COLUMN_INDEX, order=ascending) - return True - - def headerData(self, section, orientation, role): - """Get the information to put in the header.""" - if role == Qt.TextAlignmentRole: - if orientation == Qt.Horizontal: - return Qt.AlignCenter - else: - return int(Qt.AlignRight | Qt.AlignVCenter) - if role != Qt.DisplayRole and role != Qt.ToolTipRole: - return None - if self.axis == 1 and self._shape[1] <= 1: - return None - orient_axis = 0 if orientation == Qt.Horizontal else 1 - if self.model.header_shape[orient_axis] > 1: - header = section - else: - header = self.model.header(self.axis, section) - - # Don't perform any conversion on strings - # because it leads to differences between - # the data present in the dataframe and - # what is shown by Spyder - if not is_type_text_string(header): - header = to_text_string(header) - - return header - - def data(self, index, role): - """ - Get the data for the header. - - This is used when a header has levels. - """ - if not index.isValid() or \ - index.row() >= self._shape[0] or \ - index.column() >= self._shape[1]: - return None - row, col = ((index.row(), index.column()) if self.axis == 0 - else (index.column(), index.row())) - if role != Qt.DisplayRole: - return None - if self.axis == 0 and self._shape[0] <= 1: - return None - - header = self.model.header(self.axis, col, row) - - # Don't perform any conversion on strings - # because it leads to differences between - # the data present in the dataframe and - # what is shown by Spyder - if not is_type_text_string(header): - header = to_text_string(header) - - return header - - -class DataFrameLevelModel(QAbstractTableModel): - """ - Data Frame level class. - - This class is used to represent index levels in the DataFrameEditor. When - using MultiIndex, this model creates labels for the index/header as Index i - for each section in the index/header - - Based on the gtabview project (Level4ExtModel). - For more information please see: - https://github.com/wavexx/gtabview/blob/master/gtabview/viewer.py - """ - - def __init__(self, model, palette, font): - super(DataFrameLevelModel, self).__init__() - self.model = model - self._background = palette.dark().color() - if self._background.lightness() > 127: - self._foreground = palette.text() - else: - self._foreground = palette.highlightedText() - self._palette = palette - font.setBold(True) - self._font = font - - def rowCount(self, index=None): - """Get number of rows (number of levels for the header).""" - return max(1, self.model.header_shape[0]) - - def columnCount(self, index=None): - """Get the number of columns (number of levels for the index).""" - return max(1, self.model.header_shape[1]) - - def headerData(self, section, orientation, role): - """ - Get the text to put in the header of the levels of the indexes. - - By default it returns 'Index i', where i is the section in the index - """ - if role == Qt.TextAlignmentRole: - if orientation == Qt.Horizontal: - return Qt.AlignCenter - else: - return int(Qt.AlignRight | Qt.AlignVCenter) - if role != Qt.DisplayRole and role != Qt.ToolTipRole: - return None - if self.model.header_shape[0] <= 1 and orientation == Qt.Horizontal: - if self.model.name(1,section): - return self.model.name(1,section) - return _('Index') - elif self.model.header_shape[0] <= 1: - return None - elif self.model.header_shape[1] <= 1 and orientation == Qt.Vertical: - return None - return _('Index') + ' ' + to_text_string(section) - - def data(self, index, role): - """Get the information of the levels.""" - if not index.isValid(): - return None - if role == Qt.FontRole: - return self._font - label = '' - if index.column() == self.model.header_shape[1] - 1: - label = str(self.model.name(0, index.row())) - elif index.row() == self.model.header_shape[0] - 1: - label = str(self.model.name(1, index.column())) - if role == Qt.DisplayRole and label: - return label - elif role == Qt.ForegroundRole: - return self._foreground - elif role == Qt.BackgroundRole: - return self._background - elif role == Qt.BackgroundRole: - return self._palette.window() - return None - - -class DataFrameEditor(BaseDialog, SpyderConfigurationAccessor): - """ - Dialog for displaying and editing DataFrame and related objects. - - Based on the gtabview project (ExtTableView). - For more information please see: - https://github.com/wavexx/gtabview/blob/master/gtabview/viewer.py - """ - CONF_SECTION = 'variable_explorer' - - def __init__(self, parent=None): - super().__init__(parent) - - # 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.is_series = False - self.layout = None - - def setup_and_check(self, data, title=''): - """ - Setup DataFrameEditor: - return False if data is not supported, True otherwise. - Supported types for data are DataFrame, Series and Index. - """ - self._selection_rec = False - self._model = None - - self.layout = QGridLayout() - self.layout.setSpacing(0) - self.layout.setContentsMargins(20, 20, 20, 0) - self.setLayout(self.layout) - if title: - title = to_text_string(title) + " - %s" % data.__class__.__name__ - else: - title = _("%s editor") % data.__class__.__name__ - if isinstance(data, pd.Series): - self.is_series = True - data = data.to_frame() - elif isinstance(data, pd.Index): - data = pd.DataFrame(data) - - self.setWindowTitle(title) - - self.hscroll = QScrollBar(Qt.Horizontal) - self.vscroll = QScrollBar(Qt.Vertical) - - # Create the view for the level - self.create_table_level() - - # Create the view for the horizontal header - self.create_table_header() - - # Create the view for the vertical index - self.create_table_index() - - # Create the model and view of the data - self.dataModel = DataFrameModel(data, parent=self) - self.dataModel.dataChanged.connect(self.save_and_close_enable) - self.create_data_table() - - self.layout.addWidget(self.hscroll, 2, 0, 1, 2) - self.layout.addWidget(self.vscroll, 0, 2, 2, 1) - - # autosize columns on-demand - self._autosized_cols = set() - # Set limit time to calculate column sizeHint to 300ms, - # See spyder-ide/spyder#11060 - self._max_autosize_ms = 300 - self.dataTable.installEventFilter(self) - - avg_width = self.fontMetrics().averageCharWidth() - self.min_trunc = avg_width * 12 # Minimum size for columns - self.max_width = avg_width * 64 # Maximum size for columns - - self.setLayout(self.layout) - # Make the dialog act as a window - self.setWindowFlags(Qt.Window) - btn_layout = QHBoxLayout() - btn_layout.setSpacing(5) - - btn_format = QPushButton(_("Format")) - # disable format button for int type - btn_layout.addWidget(btn_format) - btn_format.clicked.connect(self.change_format) - - btn_resize = QPushButton(_('Resize')) - btn_layout.addWidget(btn_resize) - btn_resize.clicked.connect(self.resize_to_contents) - - bgcolor = QCheckBox(_('Background color')) - bgcolor.setChecked(self.dataModel.bgcolor_enabled) - bgcolor.setEnabled(self.dataModel.bgcolor_enabled) - bgcolor.stateChanged.connect(self.change_bgcolor_enable) - btn_layout.addWidget(bgcolor) - - self.bgcolor_global = QCheckBox(_('Column min/max')) - self.bgcolor_global.setChecked(self.dataModel.colum_avg_enabled) - self.bgcolor_global.setEnabled(not self.is_series and - self.dataModel.bgcolor_enabled) - self.bgcolor_global.stateChanged.connect(self.dataModel.colum_avg) - btn_layout.addWidget(self.bgcolor_global) - - btn_layout.addStretch() - - self.btn_save_and_close = QPushButton(_('Save and Close')) - self.btn_save_and_close.setDisabled(True) - self.btn_save_and_close.clicked.connect(self.accept) - btn_layout.addWidget(self.btn_save_and_close) - - self.btn_close = QPushButton(_('Close')) - self.btn_close.setAutoDefault(True) - self.btn_close.setDefault(True) - self.btn_close.clicked.connect(self.reject) - btn_layout.addWidget(self.btn_close) - - btn_layout.setContentsMargins(0, 16, 0, 16) - self.layout.addLayout(btn_layout, 4, 0, 1, 2) - self.setModel(self.dataModel) - self.resizeColumnsToContents() - - format = '%' + self.get_conf('dataframe_format') - self.dataModel.set_format(format) - - return True - - @Slot(QModelIndex, QModelIndex) - def save_and_close_enable(self, top_left, bottom_right): - """Handle the data change event to enable the save and close button.""" - self.btn_save_and_close.setEnabled(True) - self.btn_save_and_close.setAutoDefault(True) - self.btn_save_and_close.setDefault(True) - - def create_table_level(self): - """Create the QTableView that will hold the level model.""" - self.table_level = QTableView() - self.table_level.setEditTriggers(QTableWidget.NoEditTriggers) - self.table_level.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) - self.table_level.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff) - self.table_level.setFrameStyle(QFrame.Plain) - self.table_level.horizontalHeader().sectionResized.connect( - self._index_resized) - self.table_level.verticalHeader().sectionResized.connect( - self._header_resized) - self.table_level.setItemDelegate(QItemDelegate()) - self.layout.addWidget(self.table_level, 0, 0) - self.table_level.setContentsMargins(0, 0, 0, 0) - self.table_level.horizontalHeader().sectionClicked.connect( - self.sortByIndex) - - def create_table_header(self): - """Create the QTableView that will hold the header model.""" - self.table_header = QTableView() - self.table_header.verticalHeader().hide() - self.table_header.setEditTriggers(QTableWidget.NoEditTriggers) - self.table_header.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) - self.table_header.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff) - self.table_header.setHorizontalScrollMode(QTableView.ScrollPerPixel) - self.table_header.setHorizontalScrollBar(self.hscroll) - self.table_header.setFrameStyle(QFrame.Plain) - self.table_header.horizontalHeader().sectionResized.connect( - self._column_resized) - self.table_header.setItemDelegate(QItemDelegate()) - self.layout.addWidget(self.table_header, 0, 1) - - def create_table_index(self): - """Create the QTableView that will hold the index model.""" - self.table_index = QTableView() - self.table_index.horizontalHeader().hide() - self.table_index.setEditTriggers(QTableWidget.NoEditTriggers) - self.table_index.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) - self.table_index.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff) - self.table_index.setVerticalScrollMode(QTableView.ScrollPerPixel) - self.table_index.setVerticalScrollBar(self.vscroll) - self.table_index.setFrameStyle(QFrame.Plain) - self.table_index.verticalHeader().sectionResized.connect( - self._row_resized) - self.table_index.setItemDelegate(QItemDelegate()) - self.layout.addWidget(self.table_index, 1, 0) - self.table_index.setContentsMargins(0, 0, 0, 0) - - def create_data_table(self): - """Create the QTableView that will hold the data model.""" - self.dataTable = DataFrameView(self, self.dataModel, - self.table_header.horizontalHeader(), - self.hscroll, self.vscroll) - self.dataTable.verticalHeader().hide() - self.dataTable.horizontalHeader().hide() - self.dataTable.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) - self.dataTable.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff) - self.dataTable.setHorizontalScrollMode(QTableView.ScrollPerPixel) - self.dataTable.setVerticalScrollMode(QTableView.ScrollPerPixel) - self.dataTable.setFrameStyle(QFrame.Plain) - self.dataTable.setItemDelegate(QItemDelegate()) - self.layout.addWidget(self.dataTable, 1, 1) - self.setFocusProxy(self.dataTable) - self.dataTable.sig_sort_by_column.connect(self._sort_update) - self.dataTable.sig_fetch_more_columns.connect(self._fetch_more_columns) - self.dataTable.sig_fetch_more_rows.connect(self._fetch_more_rows) - - def sortByIndex(self, index): - """Implement a Index sort.""" - self.table_level.horizontalHeader().setSortIndicatorShown(True) - sort_order = self.table_level.horizontalHeader().sortIndicatorOrder() - self.table_index.model().sort(index, sort_order) - self._sort_update() - - def model(self): - """Get the model of the dataframe.""" - return self._model - - def _column_resized(self, col, old_width, new_width): - """Update the column width.""" - self.dataTable.setColumnWidth(col, new_width) - self._update_layout() - - def _row_resized(self, row, old_height, new_height): - """Update the row height.""" - self.dataTable.setRowHeight(row, new_height) - self._update_layout() - - def _index_resized(self, col, old_width, new_width): - """Resize the corresponding column of the index section selected.""" - self.table_index.setColumnWidth(col, new_width) - self._update_layout() - - def _header_resized(self, row, old_height, new_height): - """Resize the corresponding row of the header section selected.""" - self.table_header.setRowHeight(row, new_height) - self._update_layout() - - def _update_layout(self): - """Set the width and height of the QTableViews and hide rows.""" - h_width = max(self.table_level.verticalHeader().sizeHint().width(), - self.table_index.verticalHeader().sizeHint().width()) - self.table_level.verticalHeader().setFixedWidth(h_width) - self.table_index.verticalHeader().setFixedWidth(h_width) - - last_row = self._model.header_shape[0] - 1 - if last_row < 0: - hdr_height = self.table_level.horizontalHeader().height() - else: - hdr_height = self.table_level.rowViewportPosition(last_row) + \ - self.table_level.rowHeight(last_row) + \ - self.table_level.horizontalHeader().height() - # Check if the header shape has only one row (which display the - # same info than the horizontal header). - if last_row == 0: - self.table_level.setRowHidden(0, True) - self.table_header.setRowHidden(0, True) - self.table_header.setFixedHeight(hdr_height) - self.table_level.setFixedHeight(hdr_height) - - last_col = self._model.header_shape[1] - 1 - if last_col < 0: - idx_width = self.table_level.verticalHeader().width() - else: - idx_width = self.table_level.columnViewportPosition(last_col) + \ - self.table_level.columnWidth(last_col) + \ - self.table_level.verticalHeader().width() - self.table_index.setFixedWidth(idx_width) - self.table_level.setFixedWidth(idx_width) - self._resizeVisibleColumnsToContents() - - def _reset_model(self, table, model): - """Set the model in the given table.""" - old_sel_model = table.selectionModel() - table.setModel(model) - if old_sel_model: - del old_sel_model - - def setAutosizeLimitTime(self, limit_ms): - """Set maximum time to calculate size hint for columns.""" - self._max_autosize_ms = limit_ms - - def setModel(self, model, relayout=True): - """Set the model for the data, header/index and level views.""" - self._model = model - sel_model = self.dataTable.selectionModel() - sel_model.currentColumnChanged.connect( - self._resizeCurrentColumnToContents) - - # Asociate the models (level, vertical index and horizontal header) - # with its corresponding view. - self._reset_model(self.table_level, DataFrameLevelModel(model, - self.palette(), - self.font())) - self._reset_model(self.table_header, DataFrameHeaderModel( - model, - 0, - self.palette())) - self._reset_model(self.table_index, DataFrameHeaderModel( - model, - 1, - self.palette())) - - # Needs to be called after setting all table models - if relayout: - self._update_layout() - - def setCurrentIndex(self, y, x): - """Set current selection.""" - self.dataTable.selectionModel().setCurrentIndex( - self.dataTable.model().index(y, x), - QItemSelectionModel.ClearAndSelect) - - def _sizeHintForColumn(self, table, col, limit_ms=None): - """Get the size hint for a given column in a table.""" - max_row = table.model().rowCount() - lm_start = perf_counter() - lm_row = 64 if limit_ms else max_row - max_width = self.min_trunc - for row in range(max_row): - v = table.sizeHintForIndex(table.model().index(row, col)) - max_width = max(max_width, v.width()) - if row > lm_row: - lm_now = perf_counter() - lm_elapsed = (lm_now - lm_start) * 1000 - if lm_elapsed >= limit_ms: - break - lm_row = int((row / lm_elapsed) * limit_ms) - return max_width - - def _resizeColumnToContents(self, header, data, col, limit_ms): - """Resize a column by its contents.""" - hdr_width = self._sizeHintForColumn(header, col, limit_ms) - data_width = self._sizeHintForColumn(data, col, limit_ms) - if data_width > hdr_width: - width = min(self.max_width, data_width) - elif hdr_width > data_width * 2: - width = max(min(hdr_width, self.min_trunc), min(self.max_width, - data_width)) - else: - width = max(min(self.max_width, hdr_width), self.min_trunc) - header.setColumnWidth(col, width) - - def _resizeColumnsToContents(self, header, data, limit_ms): - """Resize all the colummns to its contents.""" - max_col = data.model().columnCount() - if limit_ms is None: - max_col_ms = None - else: - max_col_ms = limit_ms / max(1, max_col) - for col in range(max_col): - self._resizeColumnToContents(header, data, col, max_col_ms) - - def eventFilter(self, obj, event): - """Override eventFilter to catch resize event.""" - if obj == self.dataTable and event.type() == QEvent.Resize: - self._resizeVisibleColumnsToContents() - return False - - def _resizeVisibleColumnsToContents(self): - """Resize the columns that are in the view.""" - index_column = self.dataTable.rect().topLeft().x() - start = col = self.dataTable.columnAt(index_column) - width = self._model.shape[1] - end = self.dataTable.columnAt(self.dataTable.rect().bottomRight().x()) - end = width if end == -1 else end + 1 - if self._max_autosize_ms is None: - max_col_ms = None - else: - max_col_ms = self._max_autosize_ms / max(1, end - start) - while col < end: - resized = False - if col not in self._autosized_cols: - self._autosized_cols.add(col) - resized = True - self._resizeColumnToContents(self.table_header, self.dataTable, - col, max_col_ms) - col += 1 - if resized: - # As we resize columns, the boundary will change - index_column = self.dataTable.rect().bottomRight().x() - end = self.dataTable.columnAt(index_column) - end = width if end == -1 else end + 1 - if max_col_ms is not None: - max_col_ms = self._max_autosize_ms / max(1, end - start) - - def _resizeCurrentColumnToContents(self, new_index, old_index): - """Resize the current column to its contents.""" - if new_index.column() not in self._autosized_cols: - # Ensure the requested column is fully into view after resizing - self._resizeVisibleColumnsToContents() - self.dataTable.scrollTo(new_index) - - def resizeColumnsToContents(self): - """Resize the columns to its contents.""" - self._autosized_cols = set() - self._resizeColumnsToContents(self.table_level, - self.table_index, self._max_autosize_ms) - self._update_layout() - - def change_bgcolor_enable(self, state): - """ - This is implementet so column min/max is only active when bgcolor is - """ - self.dataModel.bgcolor(state) - self.bgcolor_global.setEnabled(not self.is_series and state > 0) - - def change_format(self): - """ - Ask user for display format for floats and use it. - """ - format, valid = QInputDialog.getText(self, _('Format'), - _("Float formatting"), - QLineEdit.Normal, - self.dataModel.get_format()) - if valid: - format = str(format) - try: - format % 1.1 - except: - msg = _("Format ({}) is incorrect").format(format) - QMessageBox.critical(self, _("Error"), msg) - return - if not format.startswith('%'): - msg = _("Format ({}) should start with '%'").format(format) - QMessageBox.critical(self, _("Error"), msg) - return - self.dataModel.set_format(format) - - format = format[1:] - self.set_conf('dataframe_format', format) - - def get_value(self): - """Return modified Dataframe -- this is *not* a copy""" - # It is import to avoid accessing Qt C++ object as it has probably - # already been destroyed, due to the Qt.WA_DeleteOnClose attribute - df = self.dataModel.get_data() - if self.is_series: - return df.iloc[:, 0] - else: - return df - - def _update_header_size(self): - """Update the column width of the header.""" - self.table_header.resizeColumnsToContents() - column_count = self.table_header.model().columnCount() - for index in range(0, column_count): - if index < column_count: - column_width = self.dataTable.columnWidth(index) - header_width = self.table_header.columnWidth(index) - if column_width > header_width: - self.table_header.setColumnWidth(index, column_width) - else: - self.dataTable.setColumnWidth(index, header_width) - else: - break - - def _sort_update(self): - """ - Update the model for all the QTableView objects. - - Uses the model of the dataTable as the base. - """ - # Update index list calculation - self.dataModel.recalculate_index() - self.setModel(self.dataTable.model()) - - def _fetch_more_columns(self): - """Fetch more data for the header (columns).""" - self.table_header.model().fetch_more() - - def _fetch_more_rows(self): - """Fetch more data for the index (rows).""" - self.table_index.model().fetch_more() - - def resize_to_contents(self): - QApplication.setOverrideCursor(QCursor(Qt.WaitCursor)) - self.dataTable.resizeColumnsToContents() - self.dataModel.fetch_more(columns=True) - self.dataTable.resizeColumnsToContents() - self._update_header_size() - QApplication.restoreOverrideCursor() - - -#============================================================================== -# Tests -#============================================================================== -def test_edit(data, title="", parent=None): - """Test subroutine""" - dlg = DataFrameEditor(parent=parent) - - if dlg.setup_and_check(data, title=title): - dlg.exec_() - return dlg.get_value() - else: - import sys - sys.exit(1) - - -def test(): - """DataFrame editor test""" - from numpy import nan - from pandas.util.testing import assert_frame_equal, assert_series_equal - - app = qapplication() # analysis:ignore - - df1 = pd.DataFrame( - [ - [True, "bool"], - [1+1j, "complex"], - ['test', "string"], - [1.11, "float"], - [1, "int"], - [np.random.rand(3, 3), "Unkown type"], - ["Large value", 100], - ["áéí", "unicode"] - ], - index=['a', 'b', nan, nan, nan, 'c', "Test global max", 'd'], - columns=[nan, 'Type'] - ) - out = test_edit(df1) - assert_frame_equal(df1, out) - - result = pd.Series([True, "bool"], index=[nan, 'Type'], name='a') - out = test_edit(df1.iloc[0]) - assert_series_equal(result, out) - - df1 = pd.DataFrame(np.random.rand(100100, 10)) - out = test_edit(df1) - assert_frame_equal(out, df1) - - series = pd.Series(np.arange(10), name=0) - out = test_edit(series) - assert_series_equal(series, out) - - -if __name__ == '__main__': - test() +# -*- coding: utf-8 -*- +# ----------------------------------------------------------------------------- +# Copyright (c) 2011-2012 Lambda Foundry, Inc. and PyData Development Team +# Copyright (c) 2013 Jev Kuznetsov and contributors +# Copyright (c) 2014-2015 Scott Hansen +# Copyright (c) 2014-2016 Yuri D'Elia "wave++" +# Copyright (c) 2014- Spyder Project Contributors +# +# Components of gtabview originally distributed under the MIT (Expat) license. +# This file as a whole distributed under the terms of the New BSD License +# (BSD 3-clause; see NOTICE.txt in the Spyder root directory for details). +# ----------------------------------------------------------------------------- + +""" +Pandas DataFrame Editor Dialog. + +DataFrameModel is based on the class ArrayModel from array editor +and the class DataFrameModel from the pandas project. +Present in pandas.sandbox.qtpandas in v0.13.1. + +DataFrameHeaderModel and DataFrameLevelModel are based on the classes +Header4ExtModel and Level4ExtModel from the gtabview project. +DataFrameModel is based on the classes ExtDataModel and ExtFrameModel, and +DataFrameEditor is based on gtExtTableView from the same project. + +DataFrameModel originally based on pandas/sandbox/qtpandas.py of the +`pandas project `_. +The current version is qtpandas/models/DataFrameModel.py of the +`QtPandas project `_. + +Components of gtabview from gtabview/viewer.py and gtabview/models.py of the +`gtabview project `_. +""" + +# Standard library imports + +# Third party imports +from qtpy.compat import from_qvariant, to_qvariant +from qtpy.QtCore import (QAbstractTableModel, QModelIndex, Qt, Signal, Slot, + QItemSelectionModel, QEvent) +from qtpy.QtGui import QColor, QCursor +from qtpy.QtWidgets import (QApplication, QCheckBox, QDialog, QGridLayout, + QHBoxLayout, QInputDialog, QLineEdit, QMenu, + QMessageBox, QPushButton, QTableView, + QScrollBar, QTableWidget, QFrame, + QItemDelegate) +from spyder_kernels.utils.lazymodules import numpy as np, pandas as pd + +# Local imports +from spyder.api.config.mixins import SpyderConfigurationAccessor +from spyder.config.base import _ +from spyder.config.fonts import DEFAULT_SMALL_DELTA +from spyder.config.gui import get_font +from spyder.py3compat import (io, is_text_string, is_type_text_string, PY2, + to_text_string, perf_counter) +from spyder.utils.icon_manager import ima +from spyder.utils.qthelpers import (add_actions, create_action, + keybinding, qapplication) +from spyder.plugins.variableexplorer.widgets.arrayeditor import get_idx_rect +from spyder.plugins.variableexplorer.widgets.basedialog import BaseDialog + +# Supported Numbers and complex numbers +REAL_NUMBER_TYPES = (float, int, np.int64, np.int32) +COMPLEX_NUMBER_TYPES = (complex, np.complex64, np.complex128) +# Used to convert bool intrance to false since bool('False') will return True +_bool_false = ['false', 'f', '0', '0.', '0.0', ' '] + +# Default format for data frames with floats +DEFAULT_FORMAT = '%.6g' + +# Limit at which dataframe is considered so large that it is loaded on demand +LARGE_SIZE = 5e5 +LARGE_NROWS = 1e5 +LARGE_COLS = 60 +ROWS_TO_LOAD = 500 +COLS_TO_LOAD = 40 + +# Background colours +BACKGROUND_NUMBER_MINHUE = 0.66 # hue for largest number +BACKGROUND_NUMBER_HUERANGE = 0.33 # (hue for smallest) minus (hue for largest) +BACKGROUND_NUMBER_SATURATION = 0.7 +BACKGROUND_NUMBER_VALUE = 1.0 +BACKGROUND_NUMBER_ALPHA = 0.6 +BACKGROUND_NONNUMBER_COLOR = Qt.lightGray +BACKGROUND_INDEX_ALPHA = 0.8 +BACKGROUND_STRING_ALPHA = 0.05 +BACKGROUND_MISC_ALPHA = 0.3 + + +def bool_false_check(value): + """ + Used to convert bool entrance to false. + + Needed since any string in bool('') will return True. + """ + if value.lower() in _bool_false: + value = '' + return value + + +def global_max(col_vals, index): + """Returns the global maximum and minimum.""" + col_vals_without_None = [x for x in col_vals if x is not None] + max_col, min_col = zip(*col_vals_without_None) + return max(max_col), min(min_col) + + +class DataFrameModel(QAbstractTableModel): + """ + DataFrame Table Model. + + Partly based in ExtDataModel and ExtFrameModel classes + of the gtabview project. + + For more information please see: + https://github.com/wavexx/gtabview/blob/master/gtabview/models.py + """ + + def __init__(self, dataFrame, format=DEFAULT_FORMAT, parent=None): + QAbstractTableModel.__init__(self) + self.dialog = parent + self.df = dataFrame + self.df_columns_list = None + self.df_index_list = None + self._format = format + self.complex_intran = None + self.display_error_idxs = [] + + self.total_rows = self.df.shape[0] + self.total_cols = self.df.shape[1] + size = self.total_rows * self.total_cols + + self.max_min_col = None + if size < LARGE_SIZE: + self.max_min_col_update() + self.colum_avg_enabled = True + self.bgcolor_enabled = True + self.colum_avg(1) + else: + self.colum_avg_enabled = False + self.bgcolor_enabled = False + self.colum_avg(0) + + # Use paging when the total size, number of rows or number of + # columns is too large + if size > LARGE_SIZE: + self.rows_loaded = ROWS_TO_LOAD + self.cols_loaded = COLS_TO_LOAD + else: + if self.total_rows > LARGE_NROWS: + self.rows_loaded = ROWS_TO_LOAD + else: + self.rows_loaded = self.total_rows + if self.total_cols > LARGE_COLS: + self.cols_loaded = COLS_TO_LOAD + else: + self.cols_loaded = self.total_cols + + def _axis(self, axis): + """ + Return the corresponding labels taking into account the axis. + + The axis could be horizontal (0) or vertical (1). + """ + return self.df.columns if axis == 0 else self.df.index + + def _axis_list(self, axis): + """ + Return the corresponding labels as a list taking into account the axis. + + The axis could be horizontal (0) or vertical (1). + """ + if axis == 0: + if self.df_columns_list is None: + self.df_columns_list = self.df.columns.tolist() + return self.df_columns_list + else: + if self.df_index_list is None: + self.df_index_list = self.df.index.tolist() + return self.df_index_list + + def _axis_levels(self, axis): + """ + Return the number of levels in the labels taking into account the axis. + + Get the number of levels for the columns (0) or rows (1). + """ + ax = self._axis(axis) + return 1 if not hasattr(ax, 'levels') else len(ax.levels) + + @property + def shape(self): + """Return the shape of the dataframe.""" + return self.df.shape + + @property + def header_shape(self): + """Return the levels for the columns and rows of the dataframe.""" + return (self._axis_levels(0), self._axis_levels(1)) + + @property + def chunk_size(self): + """Return the max value of the dimensions of the dataframe.""" + return max(*self.shape()) + + def header(self, axis, x, level=0): + """ + Return the values of the labels for the header of columns or rows. + + The value corresponds to the header of column or row x in the + given level. + """ + ax = self._axis(axis) + if not hasattr(ax, 'levels'): + ax = self._axis_list(axis) + return ax[x] + else: + return ax.values[x][level] + + def name(self, axis, level): + """Return the labels of the levels if any.""" + ax = self._axis(axis) + if hasattr(ax, 'levels'): + return ax.names[level] + if ax.name: + return ax.name + + def max_min_col_update(self): + """ + Determines the maximum and minimum number in each column. + + The result is a list whose k-th entry is [vmax, vmin], where vmax and + vmin denote the maximum and minimum of the k-th column (ignoring NaN). + This list is stored in self.max_min_col. + + If the k-th column has a non-numerical dtype, then the k-th entry + is set to None. If the dtype is complex, then compute the maximum and + minimum of the absolute values. If vmax equals vmin, then vmin is + decreased by one. + """ + if self.df.shape[0] == 0: # If no rows to compute max/min then return + return + self.max_min_col = [] + for __, col in self.df.iteritems(): + # This is necessary to catch an error in Pandas when computing + # the maximum of a column. + # Fixes spyder-ide/spyder#17145 + try: + if col.dtype in REAL_NUMBER_TYPES + COMPLEX_NUMBER_TYPES: + if col.dtype in REAL_NUMBER_TYPES: + vmax = col.max(skipna=True) + vmin = col.min(skipna=True) + else: + vmax = col.abs().max(skipna=True) + vmin = col.abs().min(skipna=True) + if vmax != vmin: + max_min = [vmax, vmin] + else: + max_min = [vmax, vmin - 1] + else: + max_min = None + except TypeError: + max_min = None + self.max_min_col.append(max_min) + + def get_format(self): + """Return current format""" + # Avoid accessing the private attribute _format from outside + return self._format + + def set_format(self, format): + """Change display format""" + self._format = format + self.reset() + + def bgcolor(self, state): + """Toggle backgroundcolor""" + self.bgcolor_enabled = state > 0 + self.reset() + + def colum_avg(self, state): + """Toggle backgroundcolor""" + self.colum_avg_enabled = state > 0 + if self.colum_avg_enabled: + self.return_max = lambda col_vals, index: col_vals[index] + else: + self.return_max = global_max + self.reset() + + def get_bgcolor(self, index): + """Background color depending on value.""" + column = index.column() + + if not self.bgcolor_enabled: + return + + value = self.get_value(index.row(), column) + if self.max_min_col[column] is None or pd.isna(value): + color = QColor(BACKGROUND_NONNUMBER_COLOR) + if is_text_string(value): + color.setAlphaF(BACKGROUND_STRING_ALPHA) + else: + color.setAlphaF(BACKGROUND_MISC_ALPHA) + else: + if isinstance(value, COMPLEX_NUMBER_TYPES): + color_func = abs + else: + color_func = float + vmax, vmin = self.return_max(self.max_min_col, column) + + # This is necessary to catch an error in Pandas when computing + # the difference between the max and min of a column. + # Fixes spyder-ide/spyder#18005 + try: + if vmax - vmin == 0: + vmax_vmin_diff = 1.0 + else: + vmax_vmin_diff = vmax - vmin + except TypeError: + return + + hue = (BACKGROUND_NUMBER_MINHUE + BACKGROUND_NUMBER_HUERANGE * + (vmax - color_func(value)) / (vmax_vmin_diff)) + hue = float(abs(hue)) + if hue > 1: + hue = 1 + color = QColor.fromHsvF(hue, BACKGROUND_NUMBER_SATURATION, + BACKGROUND_NUMBER_VALUE, + BACKGROUND_NUMBER_ALPHA) + + return color + + def get_value(self, row, column): + """Return the value of the DataFrame.""" + # To increase the performance iat is used but that requires error + # handling, so fallback uses iloc + try: + value = self.df.iat[row, column] + except pd._libs.tslib.OutOfBoundsDatetime: + value = self.df.iloc[:, column].astype(str).iat[row] + except: + value = self.df.iloc[row, column] + return value + + def data(self, index, role=Qt.DisplayRole): + """Cell content""" + if not index.isValid(): + return to_qvariant() + if role == Qt.DisplayRole or role == Qt.EditRole: + column = index.column() + row = index.row() + value = self.get_value(row, column) + if isinstance(value, float): + try: + return to_qvariant(self._format % value) + except (ValueError, TypeError): + # may happen if format = '%d' and value = NaN; + # see spyder-ide/spyder#4139. + return to_qvariant(DEFAULT_FORMAT % value) + elif is_type_text_string(value): + # Don't perform any conversion on strings + # because it leads to differences between + # the data present in the dataframe and + # what is shown by Spyder + return value + else: + try: + return to_qvariant(to_text_string(value)) + except Exception: + self.display_error_idxs.append(index) + return u'Display Error!' + elif role == Qt.BackgroundColorRole: + return to_qvariant(self.get_bgcolor(index)) + elif role == Qt.FontRole: + return to_qvariant(get_font(font_size_delta=DEFAULT_SMALL_DELTA)) + elif role == Qt.ToolTipRole: + if index in self.display_error_idxs: + return _("It is not possible to display this value because\n" + "an error ocurred while trying to do it") + return to_qvariant() + + def recalculate_index(self): + """Recalcuate index information.""" + self.df_index_list = self.df.index.tolist() + + def sort(self, column, order=Qt.AscendingOrder): + """Overriding sort method""" + if self.complex_intran is not None: + if self.complex_intran.any(axis=0).iloc[column]: + QMessageBox.critical(self.dialog, "Error", + "TypeError error: no ordering " + "relation is defined for complex numbers") + return False + try: + ascending = order == Qt.AscendingOrder + if column >= 0: + try: + self.df.sort_values(by=self.df.columns[column], + ascending=ascending, inplace=True, + kind='mergesort') + except AttributeError: + # for pandas version < 0.17 + self.df.sort(columns=self.df.columns[column], + ascending=ascending, inplace=True, + kind='mergesort') + except ValueError as e: + # Not possible to sort on duplicate columns + # See spyder-ide/spyder#5225. + QMessageBox.critical(self.dialog, "Error", + "ValueError: %s" % to_text_string(e)) + except SystemError as e: + # Not possible to sort on category dtypes + # See spyder-ide/spyder#5361. + QMessageBox.critical(self.dialog, "Error", + "SystemError: %s" % to_text_string(e)) + else: + # Update index list + self.recalculate_index() + # To sort by index + self.df.sort_index(inplace=True, ascending=ascending) + except TypeError as e: + QMessageBox.critical(self.dialog, "Error", + "TypeError error: %s" % str(e)) + return False + + self.reset() + return True + + def flags(self, index): + """Set flags""" + return Qt.ItemFlags(int(QAbstractTableModel.flags(self, index) | + Qt.ItemIsEditable)) + + def setData(self, index, value, role=Qt.EditRole, change_type=None): + """Cell content change""" + column = index.column() + row = index.row() + + if index in self.display_error_idxs: + return False + if change_type is not None: + try: + value = self.data(index, role=Qt.DisplayRole) + val = from_qvariant(value, str) + if change_type is bool: + val = bool_false_check(val) + self.df.iloc[row, column] = change_type(val) + except ValueError: + self.df.iloc[row, column] = change_type('0') + else: + val = from_qvariant(value, str) + current_value = self.get_value(row, column) + if isinstance(current_value, (bool, np.bool_)): + val = bool_false_check(val) + supported_types = (bool, np.bool_) + REAL_NUMBER_TYPES + if (isinstance(current_value, supported_types) or + is_text_string(current_value)): + try: + self.df.iloc[row, column] = current_value.__class__(val) + except (ValueError, OverflowError) as e: + QMessageBox.critical(self.dialog, "Error", + str(type(e).__name__) + ": " + str(e)) + return False + else: + QMessageBox.critical(self.dialog, "Error", + "Editing dtype {0!s} not yet supported." + .format(type(current_value).__name__)) + return False + self.max_min_col_update() + self.dataChanged.emit(index, index) + return True + + def get_data(self): + """Return data""" + return self.df + + def rowCount(self, index=QModelIndex()): + """DataFrame row number""" + # Avoid a "Qt exception in virtual methods" generated in our + # tests on Windows/Python 3.7 + # See spyder-ide/spyder#8910. + try: + if self.total_rows <= self.rows_loaded: + return self.total_rows + else: + return self.rows_loaded + except AttributeError: + return 0 + + def fetch_more(self, rows=False, columns=False): + """Get more columns and/or rows.""" + if rows and self.total_rows > self.rows_loaded: + reminder = self.total_rows - self.rows_loaded + items_to_fetch = min(reminder, ROWS_TO_LOAD) + self.beginInsertRows(QModelIndex(), self.rows_loaded, + self.rows_loaded + items_to_fetch - 1) + self.rows_loaded += items_to_fetch + self.endInsertRows() + if columns and self.total_cols > self.cols_loaded: + reminder = self.total_cols - self.cols_loaded + items_to_fetch = min(reminder, COLS_TO_LOAD) + self.beginInsertColumns(QModelIndex(), self.cols_loaded, + self.cols_loaded + items_to_fetch - 1) + self.cols_loaded += items_to_fetch + self.endInsertColumns() + + def columnCount(self, index=QModelIndex()): + """DataFrame column number""" + # Avoid a "Qt exception in virtual methods" generated in our + # tests on Windows/Python 3.7 + # See spyder-ide/spyder#8910. + try: + # This is done to implement series + if len(self.df.shape) == 1: + return 2 + elif self.total_cols <= self.cols_loaded: + return self.total_cols + else: + return self.cols_loaded + except AttributeError: + return 0 + + def reset(self): + self.beginResetModel() + self.endResetModel() + + +class DataFrameView(QTableView, SpyderConfigurationAccessor): + """ + Data Frame view class. + + Signals + ------- + sig_sort_by_column(): Raised after more columns are fetched. + sig_fetch_more_rows(): Raised after more rows are fetched. + """ + sig_sort_by_column = Signal() + sig_fetch_more_columns = Signal() + sig_fetch_more_rows = Signal() + + CONF_SECTION = 'variable_explorer' + + def __init__(self, parent, model, header, hscroll, vscroll): + """Constructor.""" + QTableView.__init__(self, parent) + self.setModel(model) + self.setHorizontalScrollBar(hscroll) + self.setVerticalScrollBar(vscroll) + self.setHorizontalScrollMode(QTableView.ScrollPerPixel) + self.setVerticalScrollMode(QTableView.ScrollPerPixel) + + self.sort_old = [None] + self.header_class = header + self.header_class.sectionClicked.connect(self.sortByColumn) + self.menu = self.setup_menu() + self.config_shortcut(self.copy, 'copy', self) + self.horizontalScrollBar().valueChanged.connect( + self._load_more_columns) + self.verticalScrollBar().valueChanged.connect(self._load_more_rows) + + def _load_more_columns(self, value): + """Load more columns to display.""" + # Needed to avoid a NameError while fetching data when closing + # See spyder-ide/spyder#12034. + try: + self.load_more_data(value, columns=True) + except NameError: + pass + + def _load_more_rows(self, value): + """Load more rows to display.""" + # Needed to avoid a NameError while fetching data when closing + # See spyder-ide/spyder#12034. + try: + self.load_more_data(value, rows=True) + except NameError: + pass + + def load_more_data(self, value, rows=False, columns=False): + """Load more rows and columns to display.""" + try: + if rows and value == self.verticalScrollBar().maximum(): + self.model().fetch_more(rows=rows) + self.sig_fetch_more_rows.emit() + if columns and value == self.horizontalScrollBar().maximum(): + self.model().fetch_more(columns=columns) + self.sig_fetch_more_columns.emit() + + except NameError: + # Needed to handle a NameError while fetching data when closing + # See spyder-ide/spyder#7880. + pass + + def sortByColumn(self, index): + """Implement a column sort.""" + if self.sort_old == [None]: + self.header_class.setSortIndicatorShown(True) + sort_order = self.header_class.sortIndicatorOrder() + if not self.model().sort(index, sort_order): + if len(self.sort_old) != 2: + self.header_class.setSortIndicatorShown(False) + else: + self.header_class.setSortIndicator(self.sort_old[0], + self.sort_old[1]) + return + self.sort_old = [index, self.header_class.sortIndicatorOrder()] + self.sig_sort_by_column.emit() + + def contextMenuEvent(self, event): + """Reimplement Qt method.""" + self.menu.popup(event.globalPos()) + event.accept() + + def setup_menu(self): + """Setup context menu.""" + copy_action = create_action(self, _('Copy'), + shortcut=keybinding('Copy'), + icon=ima.icon('editcopy'), + triggered=self.copy, + context=Qt.WidgetShortcut) + functions = ((_("To bool"), bool), (_("To complex"), complex), + (_("To int"), int), (_("To float"), float), + (_("To str"), to_text_string)) + types_in_menu = [copy_action] + for name, func in functions: + def slot(): + self.change_type(func) + types_in_menu += [create_action(self, name, + triggered=slot, + context=Qt.WidgetShortcut)] + menu = QMenu(self) + add_actions(menu, types_in_menu) + return menu + + def change_type(self, func): + """A function that changes types of cells.""" + model = self.model() + index_list = self.selectedIndexes() + [model.setData(i, '', change_type=func) for i in index_list] + + @Slot() + def copy(self): + """Copy text to clipboard""" + if not self.selectedIndexes(): + return + (row_min, row_max, + col_min, col_max) = get_idx_rect(self.selectedIndexes()) + # Copy index and header too (equal True). + # See spyder-ide/spyder#11096 + index = header = True + df = self.model().df + obj = df.iloc[slice(row_min, row_max + 1), + slice(col_min, col_max + 1)] + output = io.StringIO() + try: + obj.to_csv(output, sep='\t', index=index, header=header) + except UnicodeEncodeError: + # Needed to handle encoding errors in Python 2 + # See spyder-ide/spyder#4833 + QMessageBox.critical( + self, + _("Error"), + _("Text can't be copied.")) + if not PY2: + contents = output.getvalue() + else: + contents = output.getvalue().decode('utf-8') + output.close() + clipboard = QApplication.clipboard() + clipboard.setText(contents) + + +class DataFrameHeaderModel(QAbstractTableModel): + """ + This class is the model for the header or index of the DataFrameEditor. + + Taken from gtabview project (Header4ExtModel). + For more information please see: + https://github.com/wavexx/gtabview/blob/master/gtabview/viewer.py + """ + + COLUMN_INDEX = -1 # Makes reference to the index of the table. + + def __init__(self, model, axis, palette): + """ + Header constructor. + + The 'model' is the QAbstractTableModel of the dataframe, the 'axis' is + to acknowledge if is for the header (horizontal - 0) or for the + index (vertical - 1) and the palette is the set of colors to use. + """ + super(DataFrameHeaderModel, self).__init__() + self.model = model + self.axis = axis + self._palette = palette + self.total_rows = self.model.shape[0] + self.total_cols = self.model.shape[1] + size = self.total_rows * self.total_cols + + # Use paging when the total size, number of rows or number of + # columns is too large + if size > LARGE_SIZE: + self.rows_loaded = ROWS_TO_LOAD + self.cols_loaded = COLS_TO_LOAD + else: + if self.total_cols > LARGE_COLS: + self.cols_loaded = COLS_TO_LOAD + else: + self.cols_loaded = self.total_cols + if self.total_rows > LARGE_NROWS: + self.rows_loaded = ROWS_TO_LOAD + else: + self.rows_loaded = self.total_rows + + if self.axis == 0: + self.total_cols = self.model.shape[1] + self._shape = (self.model.header_shape[0], self.model.shape[1]) + else: + self.total_rows = self.model.shape[0] + self._shape = (self.model.shape[0], self.model.header_shape[1]) + + def rowCount(self, index=None): + """Get number of rows in the header.""" + if self.axis == 0: + return max(1, self._shape[0]) + else: + if self.total_rows <= self.rows_loaded: + return self.total_rows + else: + return self.rows_loaded + + def columnCount(self, index=QModelIndex()): + """DataFrame column number""" + if self.axis == 0: + if self.total_cols <= self.cols_loaded: + return self.total_cols + else: + return self.cols_loaded + else: + return max(1, self._shape[1]) + + def fetch_more(self, rows=False, columns=False): + """Get more columns or rows (based on axis).""" + if self.axis == 1 and self.total_rows > self.rows_loaded: + reminder = self.total_rows - self.rows_loaded + items_to_fetch = min(reminder, ROWS_TO_LOAD) + self.beginInsertRows(QModelIndex(), self.rows_loaded, + self.rows_loaded + items_to_fetch - 1) + self.rows_loaded += items_to_fetch + self.endInsertRows() + if self.axis == 0 and self.total_cols > self.cols_loaded: + reminder = self.total_cols - self.cols_loaded + items_to_fetch = min(reminder, COLS_TO_LOAD) + self.beginInsertColumns(QModelIndex(), self.cols_loaded, + self.cols_loaded + items_to_fetch - 1) + self.cols_loaded += items_to_fetch + self.endInsertColumns() + + def sort(self, column, order=Qt.AscendingOrder): + """Overriding sort method.""" + ascending = order == Qt.AscendingOrder + self.model.sort(self.COLUMN_INDEX, order=ascending) + return True + + def headerData(self, section, orientation, role): + """Get the information to put in the header.""" + if role == Qt.TextAlignmentRole: + if orientation == Qt.Horizontal: + return Qt.AlignCenter + else: + return int(Qt.AlignRight | Qt.AlignVCenter) + if role != Qt.DisplayRole and role != Qt.ToolTipRole: + return None + if self.axis == 1 and self._shape[1] <= 1: + return None + orient_axis = 0 if orientation == Qt.Horizontal else 1 + if self.model.header_shape[orient_axis] > 1: + header = section + else: + header = self.model.header(self.axis, section) + + # Don't perform any conversion on strings + # because it leads to differences between + # the data present in the dataframe and + # what is shown by Spyder + if not is_type_text_string(header): + header = to_text_string(header) + + return header + + def data(self, index, role): + """ + Get the data for the header. + + This is used when a header has levels. + """ + if not index.isValid() or \ + index.row() >= self._shape[0] or \ + index.column() >= self._shape[1]: + return None + row, col = ((index.row(), index.column()) if self.axis == 0 + else (index.column(), index.row())) + if role != Qt.DisplayRole: + return None + if self.axis == 0 and self._shape[0] <= 1: + return None + + header = self.model.header(self.axis, col, row) + + # Don't perform any conversion on strings + # because it leads to differences between + # the data present in the dataframe and + # what is shown by Spyder + if not is_type_text_string(header): + header = to_text_string(header) + + return header + + +class DataFrameLevelModel(QAbstractTableModel): + """ + Data Frame level class. + + This class is used to represent index levels in the DataFrameEditor. When + using MultiIndex, this model creates labels for the index/header as Index i + for each section in the index/header + + Based on the gtabview project (Level4ExtModel). + For more information please see: + https://github.com/wavexx/gtabview/blob/master/gtabview/viewer.py + """ + + def __init__(self, model, palette, font): + super(DataFrameLevelModel, self).__init__() + self.model = model + self._background = palette.dark().color() + if self._background.lightness() > 127: + self._foreground = palette.text() + else: + self._foreground = palette.highlightedText() + self._palette = palette + font.setBold(True) + self._font = font + + def rowCount(self, index=None): + """Get number of rows (number of levels for the header).""" + return max(1, self.model.header_shape[0]) + + def columnCount(self, index=None): + """Get the number of columns (number of levels for the index).""" + return max(1, self.model.header_shape[1]) + + def headerData(self, section, orientation, role): + """ + Get the text to put in the header of the levels of the indexes. + + By default it returns 'Index i', where i is the section in the index + """ + if role == Qt.TextAlignmentRole: + if orientation == Qt.Horizontal: + return Qt.AlignCenter + else: + return int(Qt.AlignRight | Qt.AlignVCenter) + if role != Qt.DisplayRole and role != Qt.ToolTipRole: + return None + if self.model.header_shape[0] <= 1 and orientation == Qt.Horizontal: + if self.model.name(1,section): + return self.model.name(1,section) + return _('Index') + elif self.model.header_shape[0] <= 1: + return None + elif self.model.header_shape[1] <= 1 and orientation == Qt.Vertical: + return None + return _('Index') + ' ' + to_text_string(section) + + def data(self, index, role): + """Get the information of the levels.""" + if not index.isValid(): + return None + if role == Qt.FontRole: + return self._font + label = '' + if index.column() == self.model.header_shape[1] - 1: + label = str(self.model.name(0, index.row())) + elif index.row() == self.model.header_shape[0] - 1: + label = str(self.model.name(1, index.column())) + if role == Qt.DisplayRole and label: + return label + elif role == Qt.ForegroundRole: + return self._foreground + elif role == Qt.BackgroundRole: + return self._background + elif role == Qt.BackgroundRole: + return self._palette.window() + return None + + +class DataFrameEditor(BaseDialog, SpyderConfigurationAccessor): + """ + Dialog for displaying and editing DataFrame and related objects. + + Based on the gtabview project (ExtTableView). + For more information please see: + https://github.com/wavexx/gtabview/blob/master/gtabview/viewer.py + """ + CONF_SECTION = 'variable_explorer' + + def __init__(self, parent=None): + super().__init__(parent) + + # 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.is_series = False + self.layout = None + + def setup_and_check(self, data, title=''): + """ + Setup DataFrameEditor: + return False if data is not supported, True otherwise. + Supported types for data are DataFrame, Series and Index. + """ + self._selection_rec = False + self._model = None + + self.layout = QGridLayout() + self.layout.setSpacing(0) + self.layout.setContentsMargins(20, 20, 20, 0) + self.setLayout(self.layout) + if title: + title = to_text_string(title) + " - %s" % data.__class__.__name__ + else: + title = _("%s editor") % data.__class__.__name__ + if isinstance(data, pd.Series): + self.is_series = True + data = data.to_frame() + elif isinstance(data, pd.Index): + data = pd.DataFrame(data) + + self.setWindowTitle(title) + + self.hscroll = QScrollBar(Qt.Horizontal) + self.vscroll = QScrollBar(Qt.Vertical) + + # Create the view for the level + self.create_table_level() + + # Create the view for the horizontal header + self.create_table_header() + + # Create the view for the vertical index + self.create_table_index() + + # Create the model and view of the data + self.dataModel = DataFrameModel(data, parent=self) + self.dataModel.dataChanged.connect(self.save_and_close_enable) + self.create_data_table() + + self.layout.addWidget(self.hscroll, 2, 0, 1, 2) + self.layout.addWidget(self.vscroll, 0, 2, 2, 1) + + # autosize columns on-demand + self._autosized_cols = set() + # Set limit time to calculate column sizeHint to 300ms, + # See spyder-ide/spyder#11060 + self._max_autosize_ms = 300 + self.dataTable.installEventFilter(self) + + avg_width = self.fontMetrics().averageCharWidth() + self.min_trunc = avg_width * 12 # Minimum size for columns + self.max_width = avg_width * 64 # Maximum size for columns + + self.setLayout(self.layout) + # Make the dialog act as a window + self.setWindowFlags(Qt.Window) + btn_layout = QHBoxLayout() + btn_layout.setSpacing(5) + + btn_format = QPushButton(_("Format")) + # disable format button for int type + btn_layout.addWidget(btn_format) + btn_format.clicked.connect(self.change_format) + + btn_resize = QPushButton(_('Resize')) + btn_layout.addWidget(btn_resize) + btn_resize.clicked.connect(self.resize_to_contents) + + bgcolor = QCheckBox(_('Background color')) + bgcolor.setChecked(self.dataModel.bgcolor_enabled) + bgcolor.setEnabled(self.dataModel.bgcolor_enabled) + bgcolor.stateChanged.connect(self.change_bgcolor_enable) + btn_layout.addWidget(bgcolor) + + self.bgcolor_global = QCheckBox(_('Column min/max')) + self.bgcolor_global.setChecked(self.dataModel.colum_avg_enabled) + self.bgcolor_global.setEnabled(not self.is_series and + self.dataModel.bgcolor_enabled) + self.bgcolor_global.stateChanged.connect(self.dataModel.colum_avg) + btn_layout.addWidget(self.bgcolor_global) + + btn_layout.addStretch() + + self.btn_save_and_close = QPushButton(_('Save and Close')) + self.btn_save_and_close.setDisabled(True) + self.btn_save_and_close.clicked.connect(self.accept) + btn_layout.addWidget(self.btn_save_and_close) + + self.btn_close = QPushButton(_('Close')) + self.btn_close.setAutoDefault(True) + self.btn_close.setDefault(True) + self.btn_close.clicked.connect(self.reject) + btn_layout.addWidget(self.btn_close) + + btn_layout.setContentsMargins(0, 16, 0, 16) + self.layout.addLayout(btn_layout, 4, 0, 1, 2) + self.setModel(self.dataModel) + self.resizeColumnsToContents() + + format = '%' + self.get_conf('dataframe_format') + self.dataModel.set_format(format) + + return True + + @Slot(QModelIndex, QModelIndex) + def save_and_close_enable(self, top_left, bottom_right): + """Handle the data change event to enable the save and close button.""" + self.btn_save_and_close.setEnabled(True) + self.btn_save_and_close.setAutoDefault(True) + self.btn_save_and_close.setDefault(True) + + def create_table_level(self): + """Create the QTableView that will hold the level model.""" + self.table_level = QTableView() + self.table_level.setEditTriggers(QTableWidget.NoEditTriggers) + self.table_level.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) + self.table_level.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff) + self.table_level.setFrameStyle(QFrame.Plain) + self.table_level.horizontalHeader().sectionResized.connect( + self._index_resized) + self.table_level.verticalHeader().sectionResized.connect( + self._header_resized) + self.table_level.setItemDelegate(QItemDelegate()) + self.layout.addWidget(self.table_level, 0, 0) + self.table_level.setContentsMargins(0, 0, 0, 0) + self.table_level.horizontalHeader().sectionClicked.connect( + self.sortByIndex) + + def create_table_header(self): + """Create the QTableView that will hold the header model.""" + self.table_header = QTableView() + self.table_header.verticalHeader().hide() + self.table_header.setEditTriggers(QTableWidget.NoEditTriggers) + self.table_header.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) + self.table_header.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff) + self.table_header.setHorizontalScrollMode(QTableView.ScrollPerPixel) + self.table_header.setHorizontalScrollBar(self.hscroll) + self.table_header.setFrameStyle(QFrame.Plain) + self.table_header.horizontalHeader().sectionResized.connect( + self._column_resized) + self.table_header.setItemDelegate(QItemDelegate()) + self.layout.addWidget(self.table_header, 0, 1) + + def create_table_index(self): + """Create the QTableView that will hold the index model.""" + self.table_index = QTableView() + self.table_index.horizontalHeader().hide() + self.table_index.setEditTriggers(QTableWidget.NoEditTriggers) + self.table_index.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) + self.table_index.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff) + self.table_index.setVerticalScrollMode(QTableView.ScrollPerPixel) + self.table_index.setVerticalScrollBar(self.vscroll) + self.table_index.setFrameStyle(QFrame.Plain) + self.table_index.verticalHeader().sectionResized.connect( + self._row_resized) + self.table_index.setItemDelegate(QItemDelegate()) + self.layout.addWidget(self.table_index, 1, 0) + self.table_index.setContentsMargins(0, 0, 0, 0) + + def create_data_table(self): + """Create the QTableView that will hold the data model.""" + self.dataTable = DataFrameView(self, self.dataModel, + self.table_header.horizontalHeader(), + self.hscroll, self.vscroll) + self.dataTable.verticalHeader().hide() + self.dataTable.horizontalHeader().hide() + self.dataTable.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) + self.dataTable.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff) + self.dataTable.setHorizontalScrollMode(QTableView.ScrollPerPixel) + self.dataTable.setVerticalScrollMode(QTableView.ScrollPerPixel) + self.dataTable.setFrameStyle(QFrame.Plain) + self.dataTable.setItemDelegate(QItemDelegate()) + self.layout.addWidget(self.dataTable, 1, 1) + self.setFocusProxy(self.dataTable) + self.dataTable.sig_sort_by_column.connect(self._sort_update) + self.dataTable.sig_fetch_more_columns.connect(self._fetch_more_columns) + self.dataTable.sig_fetch_more_rows.connect(self._fetch_more_rows) + + def sortByIndex(self, index): + """Implement a Index sort.""" + self.table_level.horizontalHeader().setSortIndicatorShown(True) + sort_order = self.table_level.horizontalHeader().sortIndicatorOrder() + self.table_index.model().sort(index, sort_order) + self._sort_update() + + def model(self): + """Get the model of the dataframe.""" + return self._model + + def _column_resized(self, col, old_width, new_width): + """Update the column width.""" + self.dataTable.setColumnWidth(col, new_width) + self._update_layout() + + def _row_resized(self, row, old_height, new_height): + """Update the row height.""" + self.dataTable.setRowHeight(row, new_height) + self._update_layout() + + def _index_resized(self, col, old_width, new_width): + """Resize the corresponding column of the index section selected.""" + self.table_index.setColumnWidth(col, new_width) + self._update_layout() + + def _header_resized(self, row, old_height, new_height): + """Resize the corresponding row of the header section selected.""" + self.table_header.setRowHeight(row, new_height) + self._update_layout() + + def _update_layout(self): + """Set the width and height of the QTableViews and hide rows.""" + h_width = max(self.table_level.verticalHeader().sizeHint().width(), + self.table_index.verticalHeader().sizeHint().width()) + self.table_level.verticalHeader().setFixedWidth(h_width) + self.table_index.verticalHeader().setFixedWidth(h_width) + + last_row = self._model.header_shape[0] - 1 + if last_row < 0: + hdr_height = self.table_level.horizontalHeader().height() + else: + hdr_height = self.table_level.rowViewportPosition(last_row) + \ + self.table_level.rowHeight(last_row) + \ + self.table_level.horizontalHeader().height() + # Check if the header shape has only one row (which display the + # same info than the horizontal header). + if last_row == 0: + self.table_level.setRowHidden(0, True) + self.table_header.setRowHidden(0, True) + self.table_header.setFixedHeight(hdr_height) + self.table_level.setFixedHeight(hdr_height) + + last_col = self._model.header_shape[1] - 1 + if last_col < 0: + idx_width = self.table_level.verticalHeader().width() + else: + idx_width = self.table_level.columnViewportPosition(last_col) + \ + self.table_level.columnWidth(last_col) + \ + self.table_level.verticalHeader().width() + self.table_index.setFixedWidth(idx_width) + self.table_level.setFixedWidth(idx_width) + self._resizeVisibleColumnsToContents() + + def _reset_model(self, table, model): + """Set the model in the given table.""" + old_sel_model = table.selectionModel() + table.setModel(model) + if old_sel_model: + del old_sel_model + + def setAutosizeLimitTime(self, limit_ms): + """Set maximum time to calculate size hint for columns.""" + self._max_autosize_ms = limit_ms + + def setModel(self, model, relayout=True): + """Set the model for the data, header/index and level views.""" + self._model = model + sel_model = self.dataTable.selectionModel() + sel_model.currentColumnChanged.connect( + self._resizeCurrentColumnToContents) + + # Asociate the models (level, vertical index and horizontal header) + # with its corresponding view. + self._reset_model(self.table_level, DataFrameLevelModel(model, + self.palette(), + self.font())) + self._reset_model(self.table_header, DataFrameHeaderModel( + model, + 0, + self.palette())) + self._reset_model(self.table_index, DataFrameHeaderModel( + model, + 1, + self.palette())) + + # Needs to be called after setting all table models + if relayout: + self._update_layout() + + def setCurrentIndex(self, y, x): + """Set current selection.""" + self.dataTable.selectionModel().setCurrentIndex( + self.dataTable.model().index(y, x), + QItemSelectionModel.ClearAndSelect) + + def _sizeHintForColumn(self, table, col, limit_ms=None): + """Get the size hint for a given column in a table.""" + max_row = table.model().rowCount() + lm_start = perf_counter() + lm_row = 64 if limit_ms else max_row + max_width = self.min_trunc + for row in range(max_row): + v = table.sizeHintForIndex(table.model().index(row, col)) + max_width = max(max_width, v.width()) + if row > lm_row: + lm_now = perf_counter() + lm_elapsed = (lm_now - lm_start) * 1000 + if lm_elapsed >= limit_ms: + break + lm_row = int((row / lm_elapsed) * limit_ms) + return max_width + + def _resizeColumnToContents(self, header, data, col, limit_ms): + """Resize a column by its contents.""" + hdr_width = self._sizeHintForColumn(header, col, limit_ms) + data_width = self._sizeHintForColumn(data, col, limit_ms) + if data_width > hdr_width: + width = min(self.max_width, data_width) + elif hdr_width > data_width * 2: + width = max(min(hdr_width, self.min_trunc), min(self.max_width, + data_width)) + else: + width = max(min(self.max_width, hdr_width), self.min_trunc) + header.setColumnWidth(col, width) + + def _resizeColumnsToContents(self, header, data, limit_ms): + """Resize all the colummns to its contents.""" + max_col = data.model().columnCount() + if limit_ms is None: + max_col_ms = None + else: + max_col_ms = limit_ms / max(1, max_col) + for col in range(max_col): + self._resizeColumnToContents(header, data, col, max_col_ms) + + def eventFilter(self, obj, event): + """Override eventFilter to catch resize event.""" + if obj == self.dataTable and event.type() == QEvent.Resize: + self._resizeVisibleColumnsToContents() + return False + + def _resizeVisibleColumnsToContents(self): + """Resize the columns that are in the view.""" + index_column = self.dataTable.rect().topLeft().x() + start = col = self.dataTable.columnAt(index_column) + width = self._model.shape[1] + end = self.dataTable.columnAt(self.dataTable.rect().bottomRight().x()) + end = width if end == -1 else end + 1 + if self._max_autosize_ms is None: + max_col_ms = None + else: + max_col_ms = self._max_autosize_ms / max(1, end - start) + while col < end: + resized = False + if col not in self._autosized_cols: + self._autosized_cols.add(col) + resized = True + self._resizeColumnToContents(self.table_header, self.dataTable, + col, max_col_ms) + col += 1 + if resized: + # As we resize columns, the boundary will change + index_column = self.dataTable.rect().bottomRight().x() + end = self.dataTable.columnAt(index_column) + end = width if end == -1 else end + 1 + if max_col_ms is not None: + max_col_ms = self._max_autosize_ms / max(1, end - start) + + def _resizeCurrentColumnToContents(self, new_index, old_index): + """Resize the current column to its contents.""" + if new_index.column() not in self._autosized_cols: + # Ensure the requested column is fully into view after resizing + self._resizeVisibleColumnsToContents() + self.dataTable.scrollTo(new_index) + + def resizeColumnsToContents(self): + """Resize the columns to its contents.""" + self._autosized_cols = set() + self._resizeColumnsToContents(self.table_level, + self.table_index, self._max_autosize_ms) + self._update_layout() + + def change_bgcolor_enable(self, state): + """ + This is implementet so column min/max is only active when bgcolor is + """ + self.dataModel.bgcolor(state) + self.bgcolor_global.setEnabled(not self.is_series and state > 0) + + def change_format(self): + """ + Ask user for display format for floats and use it. + """ + format, valid = QInputDialog.getText(self, _('Format'), + _("Float formatting"), + QLineEdit.Normal, + self.dataModel.get_format()) + if valid: + format = str(format) + try: + format % 1.1 + except: + msg = _("Format ({}) is incorrect").format(format) + QMessageBox.critical(self, _("Error"), msg) + return + if not format.startswith('%'): + msg = _("Format ({}) should start with '%'").format(format) + QMessageBox.critical(self, _("Error"), msg) + return + self.dataModel.set_format(format) + + format = format[1:] + self.set_conf('dataframe_format', format) + + def get_value(self): + """Return modified Dataframe -- this is *not* a copy""" + # It is import to avoid accessing Qt C++ object as it has probably + # already been destroyed, due to the Qt.WA_DeleteOnClose attribute + df = self.dataModel.get_data() + if self.is_series: + return df.iloc[:, 0] + else: + return df + + def _update_header_size(self): + """Update the column width of the header.""" + self.table_header.resizeColumnsToContents() + column_count = self.table_header.model().columnCount() + for index in range(0, column_count): + if index < column_count: + column_width = self.dataTable.columnWidth(index) + header_width = self.table_header.columnWidth(index) + if column_width > header_width: + self.table_header.setColumnWidth(index, column_width) + else: + self.dataTable.setColumnWidth(index, header_width) + else: + break + + def _sort_update(self): + """ + Update the model for all the QTableView objects. + + Uses the model of the dataTable as the base. + """ + # Update index list calculation + self.dataModel.recalculate_index() + self.setModel(self.dataTable.model()) + + def _fetch_more_columns(self): + """Fetch more data for the header (columns).""" + self.table_header.model().fetch_more() + + def _fetch_more_rows(self): + """Fetch more data for the index (rows).""" + self.table_index.model().fetch_more() + + def resize_to_contents(self): + QApplication.setOverrideCursor(QCursor(Qt.WaitCursor)) + self.dataTable.resizeColumnsToContents() + self.dataModel.fetch_more(columns=True) + self.dataTable.resizeColumnsToContents() + self._update_header_size() + QApplication.restoreOverrideCursor() + + +#============================================================================== +# Tests +#============================================================================== +def test_edit(data, title="", parent=None): + """Test subroutine""" + dlg = DataFrameEditor(parent=parent) + + if dlg.setup_and_check(data, title=title): + dlg.exec_() + return dlg.get_value() + else: + import sys + sys.exit(1) + + +def test(): + """DataFrame editor test""" + from numpy import nan + from pandas.util.testing import assert_frame_equal, assert_series_equal + + app = qapplication() # analysis:ignore + + df1 = pd.DataFrame( + [ + [True, "bool"], + [1+1j, "complex"], + ['test', "string"], + [1.11, "float"], + [1, "int"], + [np.random.rand(3, 3), "Unkown type"], + ["Large value", 100], + ["áéí", "unicode"] + ], + index=['a', 'b', nan, nan, nan, 'c', "Test global max", 'd'], + columns=[nan, 'Type'] + ) + out = test_edit(df1) + assert_frame_equal(df1, out) + + result = pd.Series([True, "bool"], index=[nan, 'Type'], name='a') + out = test_edit(df1.iloc[0]) + assert_series_equal(result, out) + + df1 = pd.DataFrame(np.random.rand(100100, 10)) + out = test_edit(df1) + assert_frame_equal(out, df1) + + series = pd.Series(np.arange(10), name=0) + out = test_edit(series) + assert_series_equal(series, out) + + +if __name__ == '__main__': + test() diff --git a/spyder/plugins/variableexplorer/widgets/importwizard.py b/spyder/plugins/variableexplorer/widgets/importwizard.py index 90393fe0ef2..23485af974e 100644 --- a/spyder/plugins/variableexplorer/widgets/importwizard.py +++ b/spyder/plugins/variableexplorer/widgets/importwizard.py @@ -1,642 +1,642 @@ -# -*- coding: utf-8 -*- -# -# Copyright © Spyder Project Contributors -# Licensed under the terms of the MIT License -# (see spyder/__init__.py for details) - -""" -Text data Importing Wizard based on Qt -""" - -# Standard library imports -import datetime -from functools import partial as ft_partial - -# Third party imports -from qtpy.compat import to_qvariant -from qtpy.QtCore import QAbstractTableModel, QModelIndex, Qt, Signal, Slot -from qtpy.QtGui import QColor, QIntValidator -from qtpy.QtWidgets import (QCheckBox, QDialog, QFrame, QGridLayout, QGroupBox, - QHBoxLayout, QLabel, QLineEdit, - QPushButton, QMenu, QMessageBox, QRadioButton, - QSizePolicy, QSpacerItem, QTableView, QTabWidget, - QTextEdit, QVBoxLayout, QWidget) -from spyder_kernels.utils.lazymodules import ( - FakeObject, numpy as np, pandas as pd) - -# Local import -from spyder.config.base import _ -from spyder.py3compat import (INT_TYPES, io, TEXT_TYPES, to_text_string, - zip_longest) -from spyder.utils import programs -from spyder.utils.icon_manager import ima -from spyder.utils.qthelpers import add_actions, create_action -from spyder.plugins.variableexplorer.widgets.basedialog import BaseDialog -from spyder.utils.palette import SpyderPalette - - -def try_to_parse(value): - _types = ('int', 'float') - for _t in _types: - try: - _val = eval("%s('%s')" % (_t, value)) - return _val - except (ValueError, SyntaxError): - pass - return value - - -def try_to_eval(value): - try: - return eval(value) - except (NameError, SyntaxError, ImportError): - return value - - -#----date and datetime objects support -try: - from dateutil.parser import parse as dateparse -except: - def dateparse(datestr, dayfirst=True): # analysis:ignore - """Just for 'day/month/year' strings""" - _a, _b, _c = list(map(int, datestr.split('/'))) - if dayfirst: - return datetime.datetime(_c, _b, _a) - return datetime.datetime(_c, _a, _b) - -def datestr_to_datetime(value, dayfirst=True): - return dateparse(value, dayfirst=dayfirst) - -#----Background colors for supported types -def get_color(value, alpha): - """Return color depending on value type""" - colors = { - bool: SpyderPalette.GROUP_1, - tuple([float] + list(INT_TYPES)): SpyderPalette.GROUP_2, - TEXT_TYPES: SpyderPalette.GROUP_3, - datetime.date: SpyderPalette.GROUP_4, - list: SpyderPalette.GROUP_5, - set: SpyderPalette.GROUP_6, - tuple: SpyderPalette.GROUP_7, - dict: SpyderPalette.GROUP_8, - np.ndarray: SpyderPalette.GROUP_9, - } - - color = QColor() - for typ in colors: - if isinstance(value, typ): - color = QColor(colors[typ]) - color.setAlphaF(alpha) - return color - - -class ContentsWidget(QWidget): - """Import wizard contents widget""" - asDataChanged = Signal(bool) - - def __init__(self, parent, text): - QWidget.__init__(self, parent) - - self.text_editor = QTextEdit(self) - self.text_editor.setText(text) - self.text_editor.setReadOnly(True) - - # Type frame - type_layout = QHBoxLayout() - type_label = QLabel(_("Import as")) - type_layout.addWidget(type_label) - data_btn = QRadioButton(_("data")) - data_btn.setChecked(True) - self._as_data= True - type_layout.addWidget(data_btn) - code_btn = QRadioButton(_("code")) - self._as_code = False - type_layout.addWidget(code_btn) - txt_btn = QRadioButton(_("text")) - type_layout.addWidget(txt_btn) - - h_spacer = QSpacerItem(40, 20, - QSizePolicy.Expanding, QSizePolicy.Minimum) - type_layout.addItem(h_spacer) - type_frame = QFrame() - type_frame.setLayout(type_layout) - - # Opts frame - grid_layout = QGridLayout() - grid_layout.setSpacing(0) - - col_label = QLabel(_("Column separator:")) - grid_layout.addWidget(col_label, 0, 0) - col_w = QWidget() - col_btn_layout = QHBoxLayout() - self.tab_btn = QRadioButton(_("Tab")) - self.tab_btn.setChecked(False) - col_btn_layout.addWidget(self.tab_btn) - self.ws_btn = QRadioButton(_("Whitespace")) - self.ws_btn.setChecked(False) - col_btn_layout.addWidget(self.ws_btn) - other_btn_col = QRadioButton(_("other")) - other_btn_col.setChecked(True) - col_btn_layout.addWidget(other_btn_col) - col_w.setLayout(col_btn_layout) - grid_layout.addWidget(col_w, 0, 1) - self.line_edt = QLineEdit(",") - self.line_edt.setMaximumWidth(30) - self.line_edt.setEnabled(True) - other_btn_col.toggled.connect(self.line_edt.setEnabled) - grid_layout.addWidget(self.line_edt, 0, 2) - - row_label = QLabel(_("Row separator:")) - grid_layout.addWidget(row_label, 1, 0) - row_w = QWidget() - row_btn_layout = QHBoxLayout() - self.eol_btn = QRadioButton(_("EOL")) - self.eol_btn.setChecked(True) - row_btn_layout.addWidget(self.eol_btn) - other_btn_row = QRadioButton(_("other")) - row_btn_layout.addWidget(other_btn_row) - row_w.setLayout(row_btn_layout) - grid_layout.addWidget(row_w, 1, 1) - self.line_edt_row = QLineEdit(";") - self.line_edt_row.setMaximumWidth(30) - self.line_edt_row.setEnabled(False) - other_btn_row.toggled.connect(self.line_edt_row.setEnabled) - grid_layout.addWidget(self.line_edt_row, 1, 2) - - grid_layout.setRowMinimumHeight(2, 15) - - other_group = QGroupBox(_("Additional options")) - other_layout = QGridLayout() - other_group.setLayout(other_layout) - - skiprows_label = QLabel(_("Skip rows:")) - other_layout.addWidget(skiprows_label, 0, 0) - self.skiprows_edt = QLineEdit('0') - self.skiprows_edt.setMaximumWidth(30) - intvalid = QIntValidator(0, len(to_text_string(text).splitlines()), - self.skiprows_edt) - self.skiprows_edt.setValidator(intvalid) - other_layout.addWidget(self.skiprows_edt, 0, 1) - - other_layout.setColumnMinimumWidth(2, 5) - - comments_label = QLabel(_("Comments:")) - other_layout.addWidget(comments_label, 0, 3) - self.comments_edt = QLineEdit('#') - self.comments_edt.setMaximumWidth(30) - other_layout.addWidget(self.comments_edt, 0, 4) - - self.trnsp_box = QCheckBox(_("Transpose")) - #self.trnsp_box.setEnabled(False) - other_layout.addWidget(self.trnsp_box, 1, 0, 2, 0) - - grid_layout.addWidget(other_group, 3, 0, 2, 0) - - opts_frame = QFrame() - opts_frame.setLayout(grid_layout) - - data_btn.toggled.connect(opts_frame.setEnabled) - data_btn.toggled.connect(self.set_as_data) - code_btn.toggled.connect(self.set_as_code) -# self.connect(txt_btn, SIGNAL("toggled(bool)"), -# self, SLOT("is_text(bool)")) - - # Final layout - layout = QVBoxLayout() - layout.addWidget(type_frame) - layout.addWidget(self.text_editor) - layout.addWidget(opts_frame) - self.setLayout(layout) - - def get_as_data(self): - """Return if data type conversion""" - return self._as_data - - def get_as_code(self): - """Return if code type conversion""" - return self._as_code - - def get_as_num(self): - """Return if numeric type conversion""" - return self._as_num - - def get_col_sep(self): - """Return the column separator""" - if self.tab_btn.isChecked(): - return u"\t" - elif self.ws_btn.isChecked(): - return None - return to_text_string(self.line_edt.text()) - - def get_row_sep(self): - """Return the row separator""" - if self.eol_btn.isChecked(): - return u"\n" - return to_text_string(self.line_edt_row.text()) - - def get_skiprows(self): - """Return number of lines to be skipped""" - return int(to_text_string(self.skiprows_edt.text())) - - def get_comments(self): - """Return comment string""" - return to_text_string(self.comments_edt.text()) - - @Slot(bool) - def set_as_data(self, as_data): - """Set if data type conversion""" - self._as_data = as_data - self.asDataChanged.emit(as_data) - - @Slot(bool) - def set_as_code(self, as_code): - """Set if code type conversion""" - self._as_code = as_code - - -class PreviewTableModel(QAbstractTableModel): - """Import wizard preview table model""" - def __init__(self, data=[], parent=None): - QAbstractTableModel.__init__(self, parent) - self._data = data - - def rowCount(self, parent=QModelIndex()): - """Return row count""" - return len(self._data) - - def columnCount(self, parent=QModelIndex()): - """Return column count""" - return len(self._data[0]) - - def _display_data(self, index): - """Return a data element""" - return to_qvariant(self._data[index.row()][index.column()]) - - def data(self, index, role=Qt.DisplayRole): - """Return a model data element""" - if not index.isValid(): - return to_qvariant() - if role == Qt.DisplayRole: - return self._display_data(index) - elif role == Qt.BackgroundColorRole: - return to_qvariant(get_color( - self._data[index.row()][index.column()], 0.5)) - elif role == Qt.TextAlignmentRole: - return to_qvariant(int(Qt.AlignRight|Qt.AlignVCenter)) - return to_qvariant() - - def setData(self, index, value, role=Qt.EditRole): - """Set model data""" - return False - - def get_data(self): - """Return a copy of model data""" - return self._data[:][:] - - def parse_data_type(self, index, **kwargs): - """Parse a type to an other type""" - if not index.isValid(): - return False - try: - if kwargs['atype'] == "date": - self._data[index.row()][index.column()] = \ - datestr_to_datetime(self._data[index.row()][index.column()], - kwargs['dayfirst']).date() - elif kwargs['atype'] == "perc": - _tmp = self._data[index.row()][index.column()].replace("%", "") - self._data[index.row()][index.column()] = eval(_tmp)/100. - elif kwargs['atype'] == "account": - _tmp = self._data[index.row()][index.column()].replace(",", "") - self._data[index.row()][index.column()] = eval(_tmp) - elif kwargs['atype'] == "unicode": - self._data[index.row()][index.column()] = to_text_string( - self._data[index.row()][index.column()]) - elif kwargs['atype'] == "int": - self._data[index.row()][index.column()] = int( - self._data[index.row()][index.column()]) - elif kwargs['atype'] == "float": - self._data[index.row()][index.column()] = float( - self._data[index.row()][index.column()]) - self.dataChanged.emit(index, index) - except Exception as instance: - print(instance) # spyder: test-skip - - def reset(self): - self.beginResetModel() - self.endResetModel() - -class PreviewTable(QTableView): - """Import wizard preview widget""" - def __init__(self, parent): - QTableView.__init__(self, parent) - self._model = None - - # Setting up actions - self.date_dayfirst_action = create_action(self, "dayfirst", - triggered=ft_partial(self.parse_to_type, atype="date", dayfirst=True)) - self.date_monthfirst_action = create_action(self, "monthfirst", - triggered=ft_partial(self.parse_to_type, atype="date", dayfirst=False)) - self.perc_action = create_action(self, "perc", - triggered=ft_partial(self.parse_to_type, atype="perc")) - self.acc_action = create_action(self, "account", - triggered=ft_partial(self.parse_to_type, atype="account")) - self.str_action = create_action(self, "unicode", - triggered=ft_partial(self.parse_to_type, atype="unicode")) - self.int_action = create_action(self, "int", - triggered=ft_partial(self.parse_to_type, atype="int")) - self.float_action = create_action(self, "float", - triggered=ft_partial(self.parse_to_type, atype="float")) - - # Setting up menus - self.date_menu = QMenu() - self.date_menu.setTitle("Date") - add_actions( self.date_menu, (self.date_dayfirst_action, - self.date_monthfirst_action)) - self.parse_menu = QMenu(self) - self.parse_menu.addMenu(self.date_menu) - add_actions( self.parse_menu, (self.perc_action, self.acc_action)) - self.parse_menu.setTitle("String to") - self.opt_menu = QMenu(self) - self.opt_menu.addMenu(self.parse_menu) - add_actions( self.opt_menu, (self.str_action, self.int_action, - self.float_action)) - - def _shape_text(self, text, colsep=u"\t", rowsep=u"\n", - transpose=False, skiprows=0, comments='#'): - """Decode the shape of the given text""" - assert colsep != rowsep - out = [] - text_rows = text.split(rowsep)[skiprows:] - for row in text_rows: - stripped = to_text_string(row).strip() - if len(stripped) == 0 or stripped.startswith(comments): - continue - line = to_text_string(row).split(colsep) - line = [try_to_parse(to_text_string(x)) for x in line] - out.append(line) - # Replace missing elements with np.nan's or None's - if programs.is_module_installed('numpy'): - from numpy import nan - out = list(zip_longest(*out, fillvalue=nan)) - else: - out = list(zip_longest(*out, fillvalue=None)) - # Tranpose the last result to get the expected one - out = [[r[col] for r in out] for col in range(len(out[0]))] - if transpose: - return [[r[col] for r in out] for col in range(len(out[0]))] - return out - - def get_data(self): - """Return model data""" - if self._model is None: - return None - return self._model.get_data() - - def process_data(self, text, colsep=u"\t", rowsep=u"\n", - transpose=False, skiprows=0, comments='#'): - """Put data into table model""" - data = self._shape_text(text, colsep, rowsep, transpose, skiprows, - comments) - self._model = PreviewTableModel(data) - self.setModel(self._model) - - @Slot() - def parse_to_type(self,**kwargs): - """Parse to a given type""" - indexes = self.selectedIndexes() - if not indexes: return - for index in indexes: - self.model().parse_data_type(index, **kwargs) - - def contextMenuEvent(self, event): - """Reimplement Qt method""" - self.opt_menu.popup(event.globalPos()) - event.accept() - - -class PreviewWidget(QWidget): - """Import wizard preview widget""" - - def __init__(self, parent): - QWidget.__init__(self, parent) - - vert_layout = QVBoxLayout() - - # Type frame - type_layout = QHBoxLayout() - type_label = QLabel(_("Import as")) - type_layout.addWidget(type_label) - - self.array_btn = array_btn = QRadioButton(_("array")) - available_array = np.ndarray is not FakeObject - array_btn.setEnabled(available_array) - array_btn.setChecked(available_array) - type_layout.addWidget(array_btn) - - list_btn = QRadioButton(_("list")) - list_btn.setChecked(not array_btn.isChecked()) - type_layout.addWidget(list_btn) - - if pd: - self.df_btn = df_btn = QRadioButton(_("DataFrame")) - df_btn.setChecked(False) - type_layout.addWidget(df_btn) - - h_spacer = QSpacerItem(40, 20, - QSizePolicy.Expanding, QSizePolicy.Minimum) - type_layout.addItem(h_spacer) - type_frame = QFrame() - type_frame.setLayout(type_layout) - - self._table_view = PreviewTable(self) - vert_layout.addWidget(type_frame) - vert_layout.addWidget(self._table_view) - self.setLayout(vert_layout) - - def open_data(self, text, colsep=u"\t", rowsep=u"\n", - transpose=False, skiprows=0, comments='#'): - """Open clipboard text as table""" - if pd: - self.pd_text = text - self.pd_info = dict(sep=colsep, lineterminator=rowsep, - skiprows=skiprows, comment=comments) - if colsep is None: - self.pd_info = dict(lineterminator=rowsep, skiprows=skiprows, - comment=comments, delim_whitespace=True) - self._table_view.process_data(text, colsep, rowsep, transpose, - skiprows, comments) - - def get_data(self): - """Return table data""" - return self._table_view.get_data() - - -class ImportWizard(BaseDialog): - """Text data import wizard""" - def __init__(self, parent, text, - title=None, icon=None, contents_title=None, varname=None): - super().__init__(parent) - - # 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) - - if title is None: - title = _("Import wizard") - self.setWindowTitle(title) - if icon is None: - self.setWindowIcon(ima.icon('fileimport')) - if contents_title is None: - contents_title = _("Raw text") - - if varname is None: - varname = _("variable_name") - - self.var_name, self.clip_data = None, None - - # Setting GUI - self.tab_widget = QTabWidget(self) - self.text_widget = ContentsWidget(self, text) - self.table_widget = PreviewWidget(self) - - self.tab_widget.addTab(self.text_widget, _("text")) - self.tab_widget.setTabText(0, contents_title) - self.tab_widget.addTab(self.table_widget, _("table")) - self.tab_widget.setTabText(1, _("Preview")) - self.tab_widget.setTabEnabled(1, False) - - name_layout = QHBoxLayout() - name_label = QLabel(_("Variable Name")) - name_layout.addWidget(name_label) - - self.name_edt = QLineEdit() - self.name_edt.setText(varname) - name_layout.addWidget(self.name_edt) - - btns_layout = QHBoxLayout() - cancel_btn = QPushButton(_("Cancel")) - btns_layout.addWidget(cancel_btn) - cancel_btn.clicked.connect(self.reject) - h_spacer = QSpacerItem(40, 20, - QSizePolicy.Expanding, QSizePolicy.Minimum) - btns_layout.addItem(h_spacer) - self.back_btn = QPushButton(_("Previous")) - self.back_btn.setEnabled(False) - btns_layout.addWidget(self.back_btn) - self.back_btn.clicked.connect(ft_partial(self._set_step, step=-1)) - self.fwd_btn = QPushButton(_("Next")) - if not text: - self.fwd_btn.setEnabled(False) - btns_layout.addWidget(self.fwd_btn) - self.fwd_btn.clicked.connect(ft_partial(self._set_step, step=1)) - self.done_btn = QPushButton(_("Done")) - self.done_btn.setEnabled(False) - btns_layout.addWidget(self.done_btn) - self.done_btn.clicked.connect(self.process) - - self.text_widget.asDataChanged.connect(self.fwd_btn.setEnabled) - self.text_widget.asDataChanged.connect(self.done_btn.setDisabled) - layout = QVBoxLayout() - layout.addLayout(name_layout) - layout.addWidget(self.tab_widget) - layout.addLayout(btns_layout) - self.setLayout(layout) - - def _focus_tab(self, tab_idx): - """Change tab focus""" - for i in range(self.tab_widget.count()): - self.tab_widget.setTabEnabled(i, False) - self.tab_widget.setTabEnabled(tab_idx, True) - self.tab_widget.setCurrentIndex(tab_idx) - - def _set_step(self, step): - """Proceed to a given step""" - new_tab = self.tab_widget.currentIndex() + step - assert new_tab < self.tab_widget.count() and new_tab >= 0 - if new_tab == self.tab_widget.count()-1: - try: - self.table_widget.open_data(self._get_plain_text(), - self.text_widget.get_col_sep(), - self.text_widget.get_row_sep(), - self.text_widget.trnsp_box.isChecked(), - self.text_widget.get_skiprows(), - self.text_widget.get_comments()) - self.done_btn.setEnabled(True) - self.done_btn.setDefault(True) - self.fwd_btn.setEnabled(False) - self.back_btn.setEnabled(True) - except (SyntaxError, AssertionError) as error: - QMessageBox.critical(self, _("Import wizard"), - _("Unable to proceed to next step" - "

    Please check your entries." - "

    Error message:
    %s") % str(error)) - return - elif new_tab == 0: - self.done_btn.setEnabled(False) - self.fwd_btn.setEnabled(True) - self.back_btn.setEnabled(False) - self._focus_tab(new_tab) - - def get_data(self): - """Return processed data""" - # It is import to avoid accessing Qt C++ object as it has probably - # already been destroyed, due to the Qt.WA_DeleteOnClose attribute - return self.var_name, self.clip_data - - def _simplify_shape(self, alist, rec=0): - """Reduce the alist dimension if needed""" - if rec != 0: - if len(alist) == 1: - return alist[-1] - return alist - if len(alist) == 1: - return self._simplify_shape(alist[-1], 1) - return [self._simplify_shape(al, 1) for al in alist] - - def _get_table_data(self): - """Return clipboard processed as data""" - data = self._simplify_shape( - self.table_widget.get_data()) - if self.table_widget.array_btn.isChecked(): - return np.array(data) - elif (pd.read_csv is not FakeObject and - self.table_widget.df_btn.isChecked()): - info = self.table_widget.pd_info - buf = io.StringIO(self.table_widget.pd_text) - return pd.read_csv(buf, **info) - return data - - def _get_plain_text(self): - """Return clipboard as text""" - return self.text_widget.text_editor.toPlainText() - - @Slot() - def process(self): - """Process the data from clipboard""" - var_name = self.name_edt.text() - try: - self.var_name = str(var_name) - except UnicodeEncodeError: - self.var_name = to_text_string(var_name) - if self.text_widget.get_as_data(): - self.clip_data = self._get_table_data() - elif self.text_widget.get_as_code(): - self.clip_data = try_to_eval( - to_text_string(self._get_plain_text())) - else: - self.clip_data = to_text_string(self._get_plain_text()) - self.accept() - - -def test(text): - """Test""" - from spyder.utils.qthelpers import qapplication - _app = qapplication() # analysis:ignore - dialog = ImportWizard(None, text) - if dialog.exec_(): - print(dialog.get_data()) # spyder: test-skip - -if __name__ == "__main__": - test(u"17/11/1976\t1.34\n14/05/09\t3.14") +# -*- coding: utf-8 -*- +# +# Copyright © Spyder Project Contributors +# Licensed under the terms of the MIT License +# (see spyder/__init__.py for details) + +""" +Text data Importing Wizard based on Qt +""" + +# Standard library imports +import datetime +from functools import partial as ft_partial + +# Third party imports +from qtpy.compat import to_qvariant +from qtpy.QtCore import QAbstractTableModel, QModelIndex, Qt, Signal, Slot +from qtpy.QtGui import QColor, QIntValidator +from qtpy.QtWidgets import (QCheckBox, QDialog, QFrame, QGridLayout, QGroupBox, + QHBoxLayout, QLabel, QLineEdit, + QPushButton, QMenu, QMessageBox, QRadioButton, + QSizePolicy, QSpacerItem, QTableView, QTabWidget, + QTextEdit, QVBoxLayout, QWidget) +from spyder_kernels.utils.lazymodules import ( + FakeObject, numpy as np, pandas as pd) + +# Local import +from spyder.config.base import _ +from spyder.py3compat import (INT_TYPES, io, TEXT_TYPES, to_text_string, + zip_longest) +from spyder.utils import programs +from spyder.utils.icon_manager import ima +from spyder.utils.qthelpers import add_actions, create_action +from spyder.plugins.variableexplorer.widgets.basedialog import BaseDialog +from spyder.utils.palette import SpyderPalette + + +def try_to_parse(value): + _types = ('int', 'float') + for _t in _types: + try: + _val = eval("%s('%s')" % (_t, value)) + return _val + except (ValueError, SyntaxError): + pass + return value + + +def try_to_eval(value): + try: + return eval(value) + except (NameError, SyntaxError, ImportError): + return value + + +#----date and datetime objects support +try: + from dateutil.parser import parse as dateparse +except: + def dateparse(datestr, dayfirst=True): # analysis:ignore + """Just for 'day/month/year' strings""" + _a, _b, _c = list(map(int, datestr.split('/'))) + if dayfirst: + return datetime.datetime(_c, _b, _a) + return datetime.datetime(_c, _a, _b) + +def datestr_to_datetime(value, dayfirst=True): + return dateparse(value, dayfirst=dayfirst) + +#----Background colors for supported types +def get_color(value, alpha): + """Return color depending on value type""" + colors = { + bool: SpyderPalette.GROUP_1, + tuple([float] + list(INT_TYPES)): SpyderPalette.GROUP_2, + TEXT_TYPES: SpyderPalette.GROUP_3, + datetime.date: SpyderPalette.GROUP_4, + list: SpyderPalette.GROUP_5, + set: SpyderPalette.GROUP_6, + tuple: SpyderPalette.GROUP_7, + dict: SpyderPalette.GROUP_8, + np.ndarray: SpyderPalette.GROUP_9, + } + + color = QColor() + for typ in colors: + if isinstance(value, typ): + color = QColor(colors[typ]) + color.setAlphaF(alpha) + return color + + +class ContentsWidget(QWidget): + """Import wizard contents widget""" + asDataChanged = Signal(bool) + + def __init__(self, parent, text): + QWidget.__init__(self, parent) + + self.text_editor = QTextEdit(self) + self.text_editor.setText(text) + self.text_editor.setReadOnly(True) + + # Type frame + type_layout = QHBoxLayout() + type_label = QLabel(_("Import as")) + type_layout.addWidget(type_label) + data_btn = QRadioButton(_("data")) + data_btn.setChecked(True) + self._as_data= True + type_layout.addWidget(data_btn) + code_btn = QRadioButton(_("code")) + self._as_code = False + type_layout.addWidget(code_btn) + txt_btn = QRadioButton(_("text")) + type_layout.addWidget(txt_btn) + + h_spacer = QSpacerItem(40, 20, + QSizePolicy.Expanding, QSizePolicy.Minimum) + type_layout.addItem(h_spacer) + type_frame = QFrame() + type_frame.setLayout(type_layout) + + # Opts frame + grid_layout = QGridLayout() + grid_layout.setSpacing(0) + + col_label = QLabel(_("Column separator:")) + grid_layout.addWidget(col_label, 0, 0) + col_w = QWidget() + col_btn_layout = QHBoxLayout() + self.tab_btn = QRadioButton(_("Tab")) + self.tab_btn.setChecked(False) + col_btn_layout.addWidget(self.tab_btn) + self.ws_btn = QRadioButton(_("Whitespace")) + self.ws_btn.setChecked(False) + col_btn_layout.addWidget(self.ws_btn) + other_btn_col = QRadioButton(_("other")) + other_btn_col.setChecked(True) + col_btn_layout.addWidget(other_btn_col) + col_w.setLayout(col_btn_layout) + grid_layout.addWidget(col_w, 0, 1) + self.line_edt = QLineEdit(",") + self.line_edt.setMaximumWidth(30) + self.line_edt.setEnabled(True) + other_btn_col.toggled.connect(self.line_edt.setEnabled) + grid_layout.addWidget(self.line_edt, 0, 2) + + row_label = QLabel(_("Row separator:")) + grid_layout.addWidget(row_label, 1, 0) + row_w = QWidget() + row_btn_layout = QHBoxLayout() + self.eol_btn = QRadioButton(_("EOL")) + self.eol_btn.setChecked(True) + row_btn_layout.addWidget(self.eol_btn) + other_btn_row = QRadioButton(_("other")) + row_btn_layout.addWidget(other_btn_row) + row_w.setLayout(row_btn_layout) + grid_layout.addWidget(row_w, 1, 1) + self.line_edt_row = QLineEdit(";") + self.line_edt_row.setMaximumWidth(30) + self.line_edt_row.setEnabled(False) + other_btn_row.toggled.connect(self.line_edt_row.setEnabled) + grid_layout.addWidget(self.line_edt_row, 1, 2) + + grid_layout.setRowMinimumHeight(2, 15) + + other_group = QGroupBox(_("Additional options")) + other_layout = QGridLayout() + other_group.setLayout(other_layout) + + skiprows_label = QLabel(_("Skip rows:")) + other_layout.addWidget(skiprows_label, 0, 0) + self.skiprows_edt = QLineEdit('0') + self.skiprows_edt.setMaximumWidth(30) + intvalid = QIntValidator(0, len(to_text_string(text).splitlines()), + self.skiprows_edt) + self.skiprows_edt.setValidator(intvalid) + other_layout.addWidget(self.skiprows_edt, 0, 1) + + other_layout.setColumnMinimumWidth(2, 5) + + comments_label = QLabel(_("Comments:")) + other_layout.addWidget(comments_label, 0, 3) + self.comments_edt = QLineEdit('#') + self.comments_edt.setMaximumWidth(30) + other_layout.addWidget(self.comments_edt, 0, 4) + + self.trnsp_box = QCheckBox(_("Transpose")) + #self.trnsp_box.setEnabled(False) + other_layout.addWidget(self.trnsp_box, 1, 0, 2, 0) + + grid_layout.addWidget(other_group, 3, 0, 2, 0) + + opts_frame = QFrame() + opts_frame.setLayout(grid_layout) + + data_btn.toggled.connect(opts_frame.setEnabled) + data_btn.toggled.connect(self.set_as_data) + code_btn.toggled.connect(self.set_as_code) +# self.connect(txt_btn, SIGNAL("toggled(bool)"), +# self, SLOT("is_text(bool)")) + + # Final layout + layout = QVBoxLayout() + layout.addWidget(type_frame) + layout.addWidget(self.text_editor) + layout.addWidget(opts_frame) + self.setLayout(layout) + + def get_as_data(self): + """Return if data type conversion""" + return self._as_data + + def get_as_code(self): + """Return if code type conversion""" + return self._as_code + + def get_as_num(self): + """Return if numeric type conversion""" + return self._as_num + + def get_col_sep(self): + """Return the column separator""" + if self.tab_btn.isChecked(): + return u"\t" + elif self.ws_btn.isChecked(): + return None + return to_text_string(self.line_edt.text()) + + def get_row_sep(self): + """Return the row separator""" + if self.eol_btn.isChecked(): + return u"\n" + return to_text_string(self.line_edt_row.text()) + + def get_skiprows(self): + """Return number of lines to be skipped""" + return int(to_text_string(self.skiprows_edt.text())) + + def get_comments(self): + """Return comment string""" + return to_text_string(self.comments_edt.text()) + + @Slot(bool) + def set_as_data(self, as_data): + """Set if data type conversion""" + self._as_data = as_data + self.asDataChanged.emit(as_data) + + @Slot(bool) + def set_as_code(self, as_code): + """Set if code type conversion""" + self._as_code = as_code + + +class PreviewTableModel(QAbstractTableModel): + """Import wizard preview table model""" + def __init__(self, data=[], parent=None): + QAbstractTableModel.__init__(self, parent) + self._data = data + + def rowCount(self, parent=QModelIndex()): + """Return row count""" + return len(self._data) + + def columnCount(self, parent=QModelIndex()): + """Return column count""" + return len(self._data[0]) + + def _display_data(self, index): + """Return a data element""" + return to_qvariant(self._data[index.row()][index.column()]) + + def data(self, index, role=Qt.DisplayRole): + """Return a model data element""" + if not index.isValid(): + return to_qvariant() + if role == Qt.DisplayRole: + return self._display_data(index) + elif role == Qt.BackgroundColorRole: + return to_qvariant(get_color( + self._data[index.row()][index.column()], 0.5)) + elif role == Qt.TextAlignmentRole: + return to_qvariant(int(Qt.AlignRight|Qt.AlignVCenter)) + return to_qvariant() + + def setData(self, index, value, role=Qt.EditRole): + """Set model data""" + return False + + def get_data(self): + """Return a copy of model data""" + return self._data[:][:] + + def parse_data_type(self, index, **kwargs): + """Parse a type to an other type""" + if not index.isValid(): + return False + try: + if kwargs['atype'] == "date": + self._data[index.row()][index.column()] = \ + datestr_to_datetime(self._data[index.row()][index.column()], + kwargs['dayfirst']).date() + elif kwargs['atype'] == "perc": + _tmp = self._data[index.row()][index.column()].replace("%", "") + self._data[index.row()][index.column()] = eval(_tmp)/100. + elif kwargs['atype'] == "account": + _tmp = self._data[index.row()][index.column()].replace(",", "") + self._data[index.row()][index.column()] = eval(_tmp) + elif kwargs['atype'] == "unicode": + self._data[index.row()][index.column()] = to_text_string( + self._data[index.row()][index.column()]) + elif kwargs['atype'] == "int": + self._data[index.row()][index.column()] = int( + self._data[index.row()][index.column()]) + elif kwargs['atype'] == "float": + self._data[index.row()][index.column()] = float( + self._data[index.row()][index.column()]) + self.dataChanged.emit(index, index) + except Exception as instance: + print(instance) # spyder: test-skip + + def reset(self): + self.beginResetModel() + self.endResetModel() + +class PreviewTable(QTableView): + """Import wizard preview widget""" + def __init__(self, parent): + QTableView.__init__(self, parent) + self._model = None + + # Setting up actions + self.date_dayfirst_action = create_action(self, "dayfirst", + triggered=ft_partial(self.parse_to_type, atype="date", dayfirst=True)) + self.date_monthfirst_action = create_action(self, "monthfirst", + triggered=ft_partial(self.parse_to_type, atype="date", dayfirst=False)) + self.perc_action = create_action(self, "perc", + triggered=ft_partial(self.parse_to_type, atype="perc")) + self.acc_action = create_action(self, "account", + triggered=ft_partial(self.parse_to_type, atype="account")) + self.str_action = create_action(self, "unicode", + triggered=ft_partial(self.parse_to_type, atype="unicode")) + self.int_action = create_action(self, "int", + triggered=ft_partial(self.parse_to_type, atype="int")) + self.float_action = create_action(self, "float", + triggered=ft_partial(self.parse_to_type, atype="float")) + + # Setting up menus + self.date_menu = QMenu() + self.date_menu.setTitle("Date") + add_actions( self.date_menu, (self.date_dayfirst_action, + self.date_monthfirst_action)) + self.parse_menu = QMenu(self) + self.parse_menu.addMenu(self.date_menu) + add_actions( self.parse_menu, (self.perc_action, self.acc_action)) + self.parse_menu.setTitle("String to") + self.opt_menu = QMenu(self) + self.opt_menu.addMenu(self.parse_menu) + add_actions( self.opt_menu, (self.str_action, self.int_action, + self.float_action)) + + def _shape_text(self, text, colsep=u"\t", rowsep=u"\n", + transpose=False, skiprows=0, comments='#'): + """Decode the shape of the given text""" + assert colsep != rowsep + out = [] + text_rows = text.split(rowsep)[skiprows:] + for row in text_rows: + stripped = to_text_string(row).strip() + if len(stripped) == 0 or stripped.startswith(comments): + continue + line = to_text_string(row).split(colsep) + line = [try_to_parse(to_text_string(x)) for x in line] + out.append(line) + # Replace missing elements with np.nan's or None's + if programs.is_module_installed('numpy'): + from numpy import nan + out = list(zip_longest(*out, fillvalue=nan)) + else: + out = list(zip_longest(*out, fillvalue=None)) + # Tranpose the last result to get the expected one + out = [[r[col] for r in out] for col in range(len(out[0]))] + if transpose: + return [[r[col] for r in out] for col in range(len(out[0]))] + return out + + def get_data(self): + """Return model data""" + if self._model is None: + return None + return self._model.get_data() + + def process_data(self, text, colsep=u"\t", rowsep=u"\n", + transpose=False, skiprows=0, comments='#'): + """Put data into table model""" + data = self._shape_text(text, colsep, rowsep, transpose, skiprows, + comments) + self._model = PreviewTableModel(data) + self.setModel(self._model) + + @Slot() + def parse_to_type(self,**kwargs): + """Parse to a given type""" + indexes = self.selectedIndexes() + if not indexes: return + for index in indexes: + self.model().parse_data_type(index, **kwargs) + + def contextMenuEvent(self, event): + """Reimplement Qt method""" + self.opt_menu.popup(event.globalPos()) + event.accept() + + +class PreviewWidget(QWidget): + """Import wizard preview widget""" + + def __init__(self, parent): + QWidget.__init__(self, parent) + + vert_layout = QVBoxLayout() + + # Type frame + type_layout = QHBoxLayout() + type_label = QLabel(_("Import as")) + type_layout.addWidget(type_label) + + self.array_btn = array_btn = QRadioButton(_("array")) + available_array = np.ndarray is not FakeObject + array_btn.setEnabled(available_array) + array_btn.setChecked(available_array) + type_layout.addWidget(array_btn) + + list_btn = QRadioButton(_("list")) + list_btn.setChecked(not array_btn.isChecked()) + type_layout.addWidget(list_btn) + + if pd: + self.df_btn = df_btn = QRadioButton(_("DataFrame")) + df_btn.setChecked(False) + type_layout.addWidget(df_btn) + + h_spacer = QSpacerItem(40, 20, + QSizePolicy.Expanding, QSizePolicy.Minimum) + type_layout.addItem(h_spacer) + type_frame = QFrame() + type_frame.setLayout(type_layout) + + self._table_view = PreviewTable(self) + vert_layout.addWidget(type_frame) + vert_layout.addWidget(self._table_view) + self.setLayout(vert_layout) + + def open_data(self, text, colsep=u"\t", rowsep=u"\n", + transpose=False, skiprows=0, comments='#'): + """Open clipboard text as table""" + if pd: + self.pd_text = text + self.pd_info = dict(sep=colsep, lineterminator=rowsep, + skiprows=skiprows, comment=comments) + if colsep is None: + self.pd_info = dict(lineterminator=rowsep, skiprows=skiprows, + comment=comments, delim_whitespace=True) + self._table_view.process_data(text, colsep, rowsep, transpose, + skiprows, comments) + + def get_data(self): + """Return table data""" + return self._table_view.get_data() + + +class ImportWizard(BaseDialog): + """Text data import wizard""" + def __init__(self, parent, text, + title=None, icon=None, contents_title=None, varname=None): + super().__init__(parent) + + # 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) + + if title is None: + title = _("Import wizard") + self.setWindowTitle(title) + if icon is None: + self.setWindowIcon(ima.icon('fileimport')) + if contents_title is None: + contents_title = _("Raw text") + + if varname is None: + varname = _("variable_name") + + self.var_name, self.clip_data = None, None + + # Setting GUI + self.tab_widget = QTabWidget(self) + self.text_widget = ContentsWidget(self, text) + self.table_widget = PreviewWidget(self) + + self.tab_widget.addTab(self.text_widget, _("text")) + self.tab_widget.setTabText(0, contents_title) + self.tab_widget.addTab(self.table_widget, _("table")) + self.tab_widget.setTabText(1, _("Preview")) + self.tab_widget.setTabEnabled(1, False) + + name_layout = QHBoxLayout() + name_label = QLabel(_("Variable Name")) + name_layout.addWidget(name_label) + + self.name_edt = QLineEdit() + self.name_edt.setText(varname) + name_layout.addWidget(self.name_edt) + + btns_layout = QHBoxLayout() + cancel_btn = QPushButton(_("Cancel")) + btns_layout.addWidget(cancel_btn) + cancel_btn.clicked.connect(self.reject) + h_spacer = QSpacerItem(40, 20, + QSizePolicy.Expanding, QSizePolicy.Minimum) + btns_layout.addItem(h_spacer) + self.back_btn = QPushButton(_("Previous")) + self.back_btn.setEnabled(False) + btns_layout.addWidget(self.back_btn) + self.back_btn.clicked.connect(ft_partial(self._set_step, step=-1)) + self.fwd_btn = QPushButton(_("Next")) + if not text: + self.fwd_btn.setEnabled(False) + btns_layout.addWidget(self.fwd_btn) + self.fwd_btn.clicked.connect(ft_partial(self._set_step, step=1)) + self.done_btn = QPushButton(_("Done")) + self.done_btn.setEnabled(False) + btns_layout.addWidget(self.done_btn) + self.done_btn.clicked.connect(self.process) + + self.text_widget.asDataChanged.connect(self.fwd_btn.setEnabled) + self.text_widget.asDataChanged.connect(self.done_btn.setDisabled) + layout = QVBoxLayout() + layout.addLayout(name_layout) + layout.addWidget(self.tab_widget) + layout.addLayout(btns_layout) + self.setLayout(layout) + + def _focus_tab(self, tab_idx): + """Change tab focus""" + for i in range(self.tab_widget.count()): + self.tab_widget.setTabEnabled(i, False) + self.tab_widget.setTabEnabled(tab_idx, True) + self.tab_widget.setCurrentIndex(tab_idx) + + def _set_step(self, step): + """Proceed to a given step""" + new_tab = self.tab_widget.currentIndex() + step + assert new_tab < self.tab_widget.count() and new_tab >= 0 + if new_tab == self.tab_widget.count()-1: + try: + self.table_widget.open_data(self._get_plain_text(), + self.text_widget.get_col_sep(), + self.text_widget.get_row_sep(), + self.text_widget.trnsp_box.isChecked(), + self.text_widget.get_skiprows(), + self.text_widget.get_comments()) + self.done_btn.setEnabled(True) + self.done_btn.setDefault(True) + self.fwd_btn.setEnabled(False) + self.back_btn.setEnabled(True) + except (SyntaxError, AssertionError) as error: + QMessageBox.critical(self, _("Import wizard"), + _("Unable to proceed to next step" + "

    Please check your entries." + "

    Error message:
    %s") % str(error)) + return + elif new_tab == 0: + self.done_btn.setEnabled(False) + self.fwd_btn.setEnabled(True) + self.back_btn.setEnabled(False) + self._focus_tab(new_tab) + + def get_data(self): + """Return processed data""" + # It is import to avoid accessing Qt C++ object as it has probably + # already been destroyed, due to the Qt.WA_DeleteOnClose attribute + return self.var_name, self.clip_data + + def _simplify_shape(self, alist, rec=0): + """Reduce the alist dimension if needed""" + if rec != 0: + if len(alist) == 1: + return alist[-1] + return alist + if len(alist) == 1: + return self._simplify_shape(alist[-1], 1) + return [self._simplify_shape(al, 1) for al in alist] + + def _get_table_data(self): + """Return clipboard processed as data""" + data = self._simplify_shape( + self.table_widget.get_data()) + if self.table_widget.array_btn.isChecked(): + return np.array(data) + elif (pd.read_csv is not FakeObject and + self.table_widget.df_btn.isChecked()): + info = self.table_widget.pd_info + buf = io.StringIO(self.table_widget.pd_text) + return pd.read_csv(buf, **info) + return data + + def _get_plain_text(self): + """Return clipboard as text""" + return self.text_widget.text_editor.toPlainText() + + @Slot() + def process(self): + """Process the data from clipboard""" + var_name = self.name_edt.text() + try: + self.var_name = str(var_name) + except UnicodeEncodeError: + self.var_name = to_text_string(var_name) + if self.text_widget.get_as_data(): + self.clip_data = self._get_table_data() + elif self.text_widget.get_as_code(): + self.clip_data = try_to_eval( + to_text_string(self._get_plain_text())) + else: + self.clip_data = to_text_string(self._get_plain_text()) + self.accept() + + +def test(text): + """Test""" + from spyder.utils.qthelpers import qapplication + _app = qapplication() # analysis:ignore + dialog = ImportWizard(None, text) + if dialog.exec_(): + print(dialog.get_data()) # spyder: test-skip + +if __name__ == "__main__": + test(u"17/11/1976\t1.34\n14/05/09\t3.14") diff --git a/spyder/plugins/variableexplorer/widgets/main_widget.py b/spyder/plugins/variableexplorer/widgets/main_widget.py index f4217ec1eaf..3a945fdb837 100644 --- a/spyder/plugins/variableexplorer/widgets/main_widget.py +++ b/spyder/plugins/variableexplorer/widgets/main_widget.py @@ -1,642 +1,642 @@ -# -*- coding: utf-8 -*- -# -# Copyright © Spyder Project Contributors -# Licensed under the terms of the MIT License -# (see spyder/__init__.py for details) - -""" -Variable Explorer Main Plugin Widget. -""" - -# Third party imports -from qtpy.QtCore import QTimer, Signal, Slot -from qtpy.QtWidgets import ( - QAction, QHBoxLayout, QWidget) - -# Local imports -from spyder.api.config.decorators import on_conf_change -from spyder.api.translations import get_translation -from spyder.api.shellconnect.main_widget import ShellConnectMainWidget -from spyder.plugins.variableexplorer.widgets.namespacebrowser import ( - NamespaceBrowser, NamespacesBrowserFinder, VALID_VARIABLE_CHARS) -from spyder.utils.programs import is_module_installed - -# Localization -_ = get_translation('spyder') - - -# ============================================================================= -# ---- Constants -# ============================================================================= -class VariableExplorerWidgetActions: - # Triggers - ImportData = 'import_data_action' - SaveData = 'save_data_action' - SaveDataAs = 'save_data_as_action' - ResetNamespace = 'reset_namespaces_action' - Search = 'search' - Refresh = 'refresh' - - # Toggles - ToggleExcludePrivate = 'toggle_exclude_private_action' - ToggleExcludeUpperCase = 'toggle_exclude_uppercase_action' - ToggleExcludeCapitalized = 'toggle_exclude_capitalized_action' - ToggleExcludeUnsupported = 'toggle_exclude_unsupported_action' - ToggleExcludeCallablesAndModules = ( - 'toggle_exclude_callables_and_modules_action') - ToggleMinMax = 'toggle_minmax_action' - - -class VariableExplorerWidgetOptionsMenuSections: - Display = 'excludes_section' - Highlight = 'highlight_section' - - -class VariableExplorerWidgetMainToolBarSections: - Main = 'main_section' - - -class VariableExplorerWidgetMenus: - EmptyContextMenu = 'empty' - PopulatedContextMenu = 'populated' - - -class VariableExplorerContextMenuActions: - ResizeRowsAction = 'resize_rows_action' - ResizeColumnsAction = 'resize_columns_action' - PasteAction = 'paste_action' - CopyAction = 'copy' - EditAction = 'edit_action' - PlotAction = 'plot_action' - HistogramAction = 'histogram_action' - ImshowAction = 'imshow_action' - SaveArrayAction = 'save_array_action' - InsertAction = 'insert_action' - RemoveAction = 'remove_action' - RenameAction = 'rename_action' - DuplicateAction = 'duplicate_action' - ViewAction = 'view_action' - - -class VariableExplorerContextMenuSections: - Edit = 'edit_section' - Insert = 'insert_section' - View = 'view_section' - Resize = 'resize_section' - - -# ============================================================================= -# ---- Widgets -# ============================================================================= - -class VariableExplorerWidget(ShellConnectMainWidget): - - # PluginMainWidget class constants - ENABLE_SPINNER = True - - # Other class constants - INITIAL_FREE_MEMORY_TIME_TRIGGER = 60 * 1000 # ms - SECONDARY_FREE_MEMORY_TIME_TRIGGER = 180 * 1000 # ms - - # Signals - sig_free_memory_requested = Signal() - - def __init__(self, name=None, plugin=None, parent=None): - super().__init__(name, plugin, parent) - - # Widgets - self.context_menu = None - self.empty_context_menu = None - - # --- Finder - self.finder = None - - # ---- PluginMainWidget API - # ------------------------------------------------------------------------ - def get_title(self): - return _('Variable Explorer') - - def setup(self): - # ---- Options menu actions - exclude_private_action = self.create_action( - VariableExplorerWidgetActions.ToggleExcludePrivate, - text=_("Exclude private variables"), - tip=_("Exclude variables that start with an underscore"), - toggled=True, - option='exclude_private', - ) - - exclude_uppercase_action = self.create_action( - VariableExplorerWidgetActions.ToggleExcludeUpperCase, - text=_("Exclude all-uppercase variables"), - tip=_("Exclude variables whose name is uppercase"), - toggled=True, - option='exclude_uppercase', - ) - - exclude_capitalized_action = self.create_action( - VariableExplorerWidgetActions.ToggleExcludeCapitalized, - text=_("Exclude capitalized variables"), - tip=_("Exclude variables whose name starts with a capital " - "letter"), - toggled=True, - option='exclude_capitalized', - ) - - exclude_unsupported_action = self.create_action( - VariableExplorerWidgetActions.ToggleExcludeUnsupported, - text=_("Exclude unsupported data types"), - tip=_("Exclude references to data types that don't have " - "an specialized viewer or can't be edited."), - toggled=True, - option='exclude_unsupported', - ) - - exclude_callables_and_modules_action = self.create_action( - VariableExplorerWidgetActions.ToggleExcludeCallablesAndModules, - text=_("Exclude callables and modules"), - tip=_("Exclude references to functions, modules and " - "any other callable."), - toggled=True, - option='exclude_callables_and_modules' - ) - - self.show_minmax_action = self.create_action( - VariableExplorerWidgetActions.ToggleMinMax, - text=_("Show arrays min/max"), - tip=_("Show minimum and maximum of arrays"), - toggled=True, - option='minmax' - ) - - # ---- Toolbar actions - import_data_action = self.create_action( - VariableExplorerWidgetActions.ImportData, - text=_('Import data'), - icon=self.create_icon('fileimport'), - triggered=lambda x: self.import_data(), - ) - - save_action = self.create_action( - VariableExplorerWidgetActions.SaveData, - text=_("Save data"), - icon=self.create_icon('filesave'), - triggered=lambda x: self.save_data(), - ) - - save_as_action = self.create_action( - VariableExplorerWidgetActions.SaveDataAs, - text=_("Save data as..."), - icon=self.create_icon('filesaveas'), - triggered=lambda x: self.save_data(), - ) - - reset_namespace_action = self.create_action( - VariableExplorerWidgetActions.ResetNamespace, - text=_("Remove all variables"), - icon=self.create_icon('editdelete'), - triggered=lambda x: self.reset_namespace(), - ) - - search_action = self.create_action( - VariableExplorerWidgetActions.Search, - text=_("Search variable names and types"), - icon=self.create_icon('find'), - toggled=self.show_finder, - register_shortcut=True - ) - - refresh_action = self.create_action( - VariableExplorerWidgetActions.Refresh, - text=_("Refresh variables"), - icon=self.create_icon('refresh'), - triggered=self.refresh_table, - register_shortcut=True, - ) - - # ---- Context menu actions - resize_rows_action = self.create_action( - VariableExplorerContextMenuActions.ResizeRowsAction, - text=_("Resize rows to contents"), - icon=self.create_icon('collapse_row'), - triggered=self.resize_rows - ) - - resize_columns_action = self.create_action( - VariableExplorerContextMenuActions.ResizeColumnsAction, - _("Resize columns to contents"), - icon=self.create_icon('collapse_column'), - triggered=self.resize_columns - ) - - self.paste_action = self.create_action( - VariableExplorerContextMenuActions.PasteAction, - _("Paste"), - icon=self.create_icon('editpaste'), - triggered=self.paste - ) - - self.copy_action = self.create_action( - VariableExplorerContextMenuActions.CopyAction, - _("Copy"), - icon=self.create_icon('editcopy'), - triggered=self.copy - ) - - self.edit_action = self.create_action( - VariableExplorerContextMenuActions.EditAction, - _("Edit"), - icon=self.create_icon('edit'), - triggered=self.edit_item - ) - - self.plot_action = self.create_action( - VariableExplorerContextMenuActions.PlotAction, - _("Plot"), - icon=self.create_icon('plot'), - triggered=self.plot_item - ) - self.plot_action.setVisible(False) - - self.hist_action = self.create_action( - VariableExplorerContextMenuActions.HistogramAction, - _("Histogram"), - icon=self.create_icon('hist'), - triggered=self.histogram_item - ) - self.hist_action.setVisible(False) - - self.imshow_action = self.create_action( - VariableExplorerContextMenuActions.ImshowAction, - _("Show image"), - icon=self.create_icon('imshow'), - triggered=self.imshow_item - ) - self.imshow_action.setVisible(False) - - self.save_array_action = self.create_action( - VariableExplorerContextMenuActions.SaveArrayAction, - _("Save array"), - icon=self.create_icon('filesave'), - triggered=self.save_array - ) - self.save_array_action.setVisible(False) - - self.insert_action = self.create_action( - VariableExplorerContextMenuActions.InsertAction, - _("Insert"), - icon=self.create_icon('insert'), - triggered=self.insert_item - ) - - self.remove_action = self.create_action( - VariableExplorerContextMenuActions.RemoveAction, - _("Remove"), - icon=self.create_icon('editdelete'), - triggered=self.remove_item - ) - - self.rename_action = self.create_action( - VariableExplorerContextMenuActions.RenameAction, - _("Rename"), - icon=self.create_icon('rename'), - triggered=self.rename_item - ) - - self.duplicate_action = self.create_action( - VariableExplorerContextMenuActions.DuplicateAction, - _("Duplicate"), - icon=self.create_icon('edit_add'), - triggered=self.duplicate_item - ) - - self.view_action = self.create_action( - VariableExplorerContextMenuActions.ViewAction, - _("View with the Object Explorer"), - icon=self.create_icon('outline_explorer'), - triggered=self.view_item - ) - - # Options menu - options_menu = self.get_options_menu() - for item in [exclude_private_action, exclude_uppercase_action, - exclude_capitalized_action, exclude_unsupported_action, - exclude_callables_and_modules_action, - self.show_minmax_action]: - self.add_item_to_menu( - item, - menu=options_menu, - section=VariableExplorerWidgetOptionsMenuSections.Display, - ) - - # Main toolbar - main_toolbar = self.get_main_toolbar() - for item in [import_data_action, save_action, save_as_action, - reset_namespace_action, search_action, refresh_action]: - self.add_item_to_toolbar( - item, - toolbar=main_toolbar, - section=VariableExplorerWidgetMainToolBarSections.Main, - ) - save_action.setEnabled(False) - - # ---- Context menu to show when there are variables present - self.context_menu = self.create_menu( - VariableExplorerWidgetMenus.PopulatedContextMenu) - for item in [self.edit_action, self.copy_action, self.paste_action, - self.rename_action, self.remove_action, - self.save_array_action]: - self.add_item_to_menu( - item, - menu=self.context_menu, - section=VariableExplorerContextMenuSections.Edit, - ) - - for item in [self.insert_action, self.duplicate_action]: - self.add_item_to_menu( - item, - menu=self.context_menu, - section=VariableExplorerContextMenuSections.Insert, - ) - - for item in [self.view_action, self.plot_action, self.hist_action, - self.imshow_action, self.show_minmax_action]: - self.add_item_to_menu( - item, - menu=self.context_menu, - section=VariableExplorerContextMenuSections.View, - ) - - for item in [resize_rows_action, resize_columns_action]: - self.add_item_to_menu( - item, - menu=self.context_menu, - section=VariableExplorerContextMenuSections.Resize, - ) - - # ---- Context menu when the variable explorer is empty - self.empty_context_menu = self.create_menu( - VariableExplorerWidgetMenus.EmptyContextMenu) - for item in [self.insert_action, self.paste_action]: - self.add_item_to_menu( - item, - menu=self.empty_context_menu, - section=VariableExplorerContextMenuSections.Edit, - ) - - def update_actions(self): - action = self.get_action(VariableExplorerWidgetActions.ToggleMinMax) - action.setEnabled(is_module_installed('numpy')) - nsb = self.current_widget() - - for __, action in self.get_actions().items(): - if action: - # IMPORTANT: Since we are defining the main actions in here - # and the context is WidgetWithChildrenShortcut we need to - # assign the same actions to the children widgets in order - # for shortcuts to work - if nsb: - save_data_action = self.get_action( - VariableExplorerWidgetActions.SaveData) - save_data_action.setEnabled(nsb.filename is not None) - - nsb_actions = nsb.actions() - if action not in nsb_actions: - nsb.addAction(action) - - @on_conf_change - def on_section_conf_change(self, section): - for index in range(self.count()): - widget = self._stack.widget(index) - if widget: - widget.setup() - - # ---- Stack accesors - # ------------------------------------------------------------------------ - def update_finder(self, nsb, old_nsb): - """Initialize or update finder widget.""" - if self.finder is None: - # Initialize finder/search related widgets - self.finder = QWidget(self) - self.text_finder = NamespacesBrowserFinder( - nsb.editor, - callback=nsb.editor.set_regex, - main=nsb, - regex_base=VALID_VARIABLE_CHARS) - self.finder.text_finder = self.text_finder - self.finder_close_button = self.create_toolbutton( - 'close_finder', - triggered=self.hide_finder, - icon=self.create_icon('DialogCloseButton'), - ) - - finder_layout = QHBoxLayout() - finder_layout.addWidget(self.finder_close_button) - finder_layout.addWidget(self.text_finder) - finder_layout.setContentsMargins(0, 0, 0, 0) - self.finder.setLayout(finder_layout) - - layout = self.layout() - layout.addSpacing(1) - layout.addWidget(self.finder) - else: - # Just update references to the same text_finder (Custom QLineEdit) - # widget to the new current NamespaceBrowser and save current - # finder state in the previous NamespaceBrowser - if old_nsb is not None: - self.save_finder_state(old_nsb) - self.text_finder.update_parent( - nsb.editor, - callback=nsb.editor.set_regex, - main=nsb, - ) - - def switch_widget(self, nsb, old_nsb): - """ - Set the current NamespaceBrowser. - - This also setup the finder widget to work with the current - NamespaceBrowser. - """ - self.update_finder(nsb, old_nsb) - finder_visible = nsb.set_text_finder(self.text_finder) - self.finder.setVisible(finder_visible) - search_action = self.get_action(VariableExplorerWidgetActions.Search) - search_action.setChecked(finder_visible) - - # ---- Public API - # ------------------------------------------------------------------------ - - def create_new_widget(self, shellwidget): - nsb = NamespaceBrowser(self) - nsb.set_shellwidget(shellwidget) - nsb.setup() - nsb.sig_free_memory_requested.connect( - self.free_memory) - nsb.sig_start_spinner_requested.connect( - self.start_spinner) - nsb.sig_stop_spinner_requested.connect( - self.stop_spinner) - nsb.sig_hide_finder_requested.connect( - self.hide_finder) - self._set_actions_and_menus(nsb) - return nsb - - def close_widget(self, nsb): - nsb.close() - - def import_data(self, filenames=None): - """ - Import data in current namespace. - """ - if self.count(): - nsb = self.current_widget() - nsb.refresh_table() - nsb.import_data(filenames=filenames) - - def save_data(self): - if self.count(): - nsb = self.current_widget() - nsb.save_data() - self.update_actions() - - def reset_namespace(self): - if self.count(): - nsb = self.current_widget() - nsb.reset_namespace() - - @Slot(bool) - def show_finder(self, checked): - if self.count(): - nsb = self.current_widget() - if checked: - self.finder.text_finder.setText(nsb.last_find) - else: - self.save_finder_state(nsb) - self.finder.text_finder.setText('') - self.finder.setVisible(checked) - if self.finder.isVisible(): - self.finder.text_finder.setFocus() - else: - nsb.editor.setFocus() - - @Slot() - def hide_finder(self): - action = self.get_action(VariableExplorerWidgetActions.Search) - action.setChecked(False) - nsb = self.current_widget() - self.save_finder_state(nsb) - self.finder.text_finder.setText('') - - def save_finder_state(self, nsb): - """ - Save finder state (last input text and visibility). - - The values are saved in the given NamespaceBrowser. - """ - last_find = self.text_finder.text() - finder_visibility = self.finder.isVisible() - nsb.save_finder_state(last_find, finder_visibility) - - def refresh_table(self): - if self.count(): - nsb = self.current_widget() - nsb.refresh_table() - - @Slot() - def free_memory(self): - """ - Free memory signal. - """ - self.sig_free_memory_requested.emit() - QTimer.singleShot(self.INITIAL_FREE_MEMORY_TIME_TRIGGER, - self.sig_free_memory_requested) - QTimer.singleShot(self.SECONDARY_FREE_MEMORY_TIME_TRIGGER, - self.sig_free_memory_requested) - - def resize_rows(self): - self._current_editor.resizeRowsToContents() - - def resize_columns(self): - self._current_editor.resize_column_contents() - - def paste(self): - self._current_editor.paste() - - def copy(self): - self._current_editor.copy() - - def edit_item(self): - self._current_editor.edit_item() - - def plot_item(self): - self._current_editor.plot_item('plot') - - def histogram_item(self): - self._current_editor.plot_item('hist') - - def imshow_item(self): - self._current_editor.imshow_item() - - def save_array(self): - self._current_editor.save_array() - - def insert_item(self): - self._current_editor.insert_item(below=False) - - def remove_item(self): - self._current_editor.remove_item() - - def rename_item(self): - self._current_editor.rename_item() - - def duplicate_item(self): - self._current_editor.duplicate_item() - - def view_item(self): - self._current_editor.view_item() - - # ---- Private API - # ------------------------------------------------------------------------ - @property - def _current_editor(self): - editor = None - if self.count(): - nsb = self.current_widget() - editor = nsb.editor - return editor - - def _set_actions_and_menus(self, nsb): - """ - Set actions and menus created here and used by the namespace - browser editor. - - Although this is not ideal, it's necessary to be able to use - the CollectionsEditor widget separately from this plugin. - """ - editor = nsb.editor - - # Actions - editor.paste_action = self.paste_action - editor.copy_action = self.copy_action - editor.edit_action = self.edit_action - editor.plot_action = self.plot_action - editor.hist_action = self.hist_action - editor.imshow_action = self.imshow_action - editor.save_array_action = self.save_array_action - editor.insert_action = self.insert_action - editor.remove_action = self.remove_action - editor.minmax_action = self.show_minmax_action - editor.rename_action = self.rename_action - editor.duplicate_action = self.duplicate_action - editor.view_action = self.view_action - - # Menus - editor.menu = self.context_menu - editor.empty_ws_menu = self.empty_context_menu - - # These actions are not used for dictionaries (so we don't need them - # for namespaces) but we have to create them so they can be used in - # several places in CollectionsEditor. - editor.insert_action_above = QAction() - editor.insert_action_below = QAction() +# -*- coding: utf-8 -*- +# +# Copyright © Spyder Project Contributors +# Licensed under the terms of the MIT License +# (see spyder/__init__.py for details) + +""" +Variable Explorer Main Plugin Widget. +""" + +# Third party imports +from qtpy.QtCore import QTimer, Signal, Slot +from qtpy.QtWidgets import ( + QAction, QHBoxLayout, QWidget) + +# Local imports +from spyder.api.config.decorators import on_conf_change +from spyder.api.translations import get_translation +from spyder.api.shellconnect.main_widget import ShellConnectMainWidget +from spyder.plugins.variableexplorer.widgets.namespacebrowser import ( + NamespaceBrowser, NamespacesBrowserFinder, VALID_VARIABLE_CHARS) +from spyder.utils.programs import is_module_installed + +# Localization +_ = get_translation('spyder') + + +# ============================================================================= +# ---- Constants +# ============================================================================= +class VariableExplorerWidgetActions: + # Triggers + ImportData = 'import_data_action' + SaveData = 'save_data_action' + SaveDataAs = 'save_data_as_action' + ResetNamespace = 'reset_namespaces_action' + Search = 'search' + Refresh = 'refresh' + + # Toggles + ToggleExcludePrivate = 'toggle_exclude_private_action' + ToggleExcludeUpperCase = 'toggle_exclude_uppercase_action' + ToggleExcludeCapitalized = 'toggle_exclude_capitalized_action' + ToggleExcludeUnsupported = 'toggle_exclude_unsupported_action' + ToggleExcludeCallablesAndModules = ( + 'toggle_exclude_callables_and_modules_action') + ToggleMinMax = 'toggle_minmax_action' + + +class VariableExplorerWidgetOptionsMenuSections: + Display = 'excludes_section' + Highlight = 'highlight_section' + + +class VariableExplorerWidgetMainToolBarSections: + Main = 'main_section' + + +class VariableExplorerWidgetMenus: + EmptyContextMenu = 'empty' + PopulatedContextMenu = 'populated' + + +class VariableExplorerContextMenuActions: + ResizeRowsAction = 'resize_rows_action' + ResizeColumnsAction = 'resize_columns_action' + PasteAction = 'paste_action' + CopyAction = 'copy' + EditAction = 'edit_action' + PlotAction = 'plot_action' + HistogramAction = 'histogram_action' + ImshowAction = 'imshow_action' + SaveArrayAction = 'save_array_action' + InsertAction = 'insert_action' + RemoveAction = 'remove_action' + RenameAction = 'rename_action' + DuplicateAction = 'duplicate_action' + ViewAction = 'view_action' + + +class VariableExplorerContextMenuSections: + Edit = 'edit_section' + Insert = 'insert_section' + View = 'view_section' + Resize = 'resize_section' + + +# ============================================================================= +# ---- Widgets +# ============================================================================= + +class VariableExplorerWidget(ShellConnectMainWidget): + + # PluginMainWidget class constants + ENABLE_SPINNER = True + + # Other class constants + INITIAL_FREE_MEMORY_TIME_TRIGGER = 60 * 1000 # ms + SECONDARY_FREE_MEMORY_TIME_TRIGGER = 180 * 1000 # ms + + # Signals + sig_free_memory_requested = Signal() + + def __init__(self, name=None, plugin=None, parent=None): + super().__init__(name, plugin, parent) + + # Widgets + self.context_menu = None + self.empty_context_menu = None + + # --- Finder + self.finder = None + + # ---- PluginMainWidget API + # ------------------------------------------------------------------------ + def get_title(self): + return _('Variable Explorer') + + def setup(self): + # ---- Options menu actions + exclude_private_action = self.create_action( + VariableExplorerWidgetActions.ToggleExcludePrivate, + text=_("Exclude private variables"), + tip=_("Exclude variables that start with an underscore"), + toggled=True, + option='exclude_private', + ) + + exclude_uppercase_action = self.create_action( + VariableExplorerWidgetActions.ToggleExcludeUpperCase, + text=_("Exclude all-uppercase variables"), + tip=_("Exclude variables whose name is uppercase"), + toggled=True, + option='exclude_uppercase', + ) + + exclude_capitalized_action = self.create_action( + VariableExplorerWidgetActions.ToggleExcludeCapitalized, + text=_("Exclude capitalized variables"), + tip=_("Exclude variables whose name starts with a capital " + "letter"), + toggled=True, + option='exclude_capitalized', + ) + + exclude_unsupported_action = self.create_action( + VariableExplorerWidgetActions.ToggleExcludeUnsupported, + text=_("Exclude unsupported data types"), + tip=_("Exclude references to data types that don't have " + "an specialized viewer or can't be edited."), + toggled=True, + option='exclude_unsupported', + ) + + exclude_callables_and_modules_action = self.create_action( + VariableExplorerWidgetActions.ToggleExcludeCallablesAndModules, + text=_("Exclude callables and modules"), + tip=_("Exclude references to functions, modules and " + "any other callable."), + toggled=True, + option='exclude_callables_and_modules' + ) + + self.show_minmax_action = self.create_action( + VariableExplorerWidgetActions.ToggleMinMax, + text=_("Show arrays min/max"), + tip=_("Show minimum and maximum of arrays"), + toggled=True, + option='minmax' + ) + + # ---- Toolbar actions + import_data_action = self.create_action( + VariableExplorerWidgetActions.ImportData, + text=_('Import data'), + icon=self.create_icon('fileimport'), + triggered=lambda x: self.import_data(), + ) + + save_action = self.create_action( + VariableExplorerWidgetActions.SaveData, + text=_("Save data"), + icon=self.create_icon('filesave'), + triggered=lambda x: self.save_data(), + ) + + save_as_action = self.create_action( + VariableExplorerWidgetActions.SaveDataAs, + text=_("Save data as..."), + icon=self.create_icon('filesaveas'), + triggered=lambda x: self.save_data(), + ) + + reset_namespace_action = self.create_action( + VariableExplorerWidgetActions.ResetNamespace, + text=_("Remove all variables"), + icon=self.create_icon('editdelete'), + triggered=lambda x: self.reset_namespace(), + ) + + search_action = self.create_action( + VariableExplorerWidgetActions.Search, + text=_("Search variable names and types"), + icon=self.create_icon('find'), + toggled=self.show_finder, + register_shortcut=True + ) + + refresh_action = self.create_action( + VariableExplorerWidgetActions.Refresh, + text=_("Refresh variables"), + icon=self.create_icon('refresh'), + triggered=self.refresh_table, + register_shortcut=True, + ) + + # ---- Context menu actions + resize_rows_action = self.create_action( + VariableExplorerContextMenuActions.ResizeRowsAction, + text=_("Resize rows to contents"), + icon=self.create_icon('collapse_row'), + triggered=self.resize_rows + ) + + resize_columns_action = self.create_action( + VariableExplorerContextMenuActions.ResizeColumnsAction, + _("Resize columns to contents"), + icon=self.create_icon('collapse_column'), + triggered=self.resize_columns + ) + + self.paste_action = self.create_action( + VariableExplorerContextMenuActions.PasteAction, + _("Paste"), + icon=self.create_icon('editpaste'), + triggered=self.paste + ) + + self.copy_action = self.create_action( + VariableExplorerContextMenuActions.CopyAction, + _("Copy"), + icon=self.create_icon('editcopy'), + triggered=self.copy + ) + + self.edit_action = self.create_action( + VariableExplorerContextMenuActions.EditAction, + _("Edit"), + icon=self.create_icon('edit'), + triggered=self.edit_item + ) + + self.plot_action = self.create_action( + VariableExplorerContextMenuActions.PlotAction, + _("Plot"), + icon=self.create_icon('plot'), + triggered=self.plot_item + ) + self.plot_action.setVisible(False) + + self.hist_action = self.create_action( + VariableExplorerContextMenuActions.HistogramAction, + _("Histogram"), + icon=self.create_icon('hist'), + triggered=self.histogram_item + ) + self.hist_action.setVisible(False) + + self.imshow_action = self.create_action( + VariableExplorerContextMenuActions.ImshowAction, + _("Show image"), + icon=self.create_icon('imshow'), + triggered=self.imshow_item + ) + self.imshow_action.setVisible(False) + + self.save_array_action = self.create_action( + VariableExplorerContextMenuActions.SaveArrayAction, + _("Save array"), + icon=self.create_icon('filesave'), + triggered=self.save_array + ) + self.save_array_action.setVisible(False) + + self.insert_action = self.create_action( + VariableExplorerContextMenuActions.InsertAction, + _("Insert"), + icon=self.create_icon('insert'), + triggered=self.insert_item + ) + + self.remove_action = self.create_action( + VariableExplorerContextMenuActions.RemoveAction, + _("Remove"), + icon=self.create_icon('editdelete'), + triggered=self.remove_item + ) + + self.rename_action = self.create_action( + VariableExplorerContextMenuActions.RenameAction, + _("Rename"), + icon=self.create_icon('rename'), + triggered=self.rename_item + ) + + self.duplicate_action = self.create_action( + VariableExplorerContextMenuActions.DuplicateAction, + _("Duplicate"), + icon=self.create_icon('edit_add'), + triggered=self.duplicate_item + ) + + self.view_action = self.create_action( + VariableExplorerContextMenuActions.ViewAction, + _("View with the Object Explorer"), + icon=self.create_icon('outline_explorer'), + triggered=self.view_item + ) + + # Options menu + options_menu = self.get_options_menu() + for item in [exclude_private_action, exclude_uppercase_action, + exclude_capitalized_action, exclude_unsupported_action, + exclude_callables_and_modules_action, + self.show_minmax_action]: + self.add_item_to_menu( + item, + menu=options_menu, + section=VariableExplorerWidgetOptionsMenuSections.Display, + ) + + # Main toolbar + main_toolbar = self.get_main_toolbar() + for item in [import_data_action, save_action, save_as_action, + reset_namespace_action, search_action, refresh_action]: + self.add_item_to_toolbar( + item, + toolbar=main_toolbar, + section=VariableExplorerWidgetMainToolBarSections.Main, + ) + save_action.setEnabled(False) + + # ---- Context menu to show when there are variables present + self.context_menu = self.create_menu( + VariableExplorerWidgetMenus.PopulatedContextMenu) + for item in [self.edit_action, self.copy_action, self.paste_action, + self.rename_action, self.remove_action, + self.save_array_action]: + self.add_item_to_menu( + item, + menu=self.context_menu, + section=VariableExplorerContextMenuSections.Edit, + ) + + for item in [self.insert_action, self.duplicate_action]: + self.add_item_to_menu( + item, + menu=self.context_menu, + section=VariableExplorerContextMenuSections.Insert, + ) + + for item in [self.view_action, self.plot_action, self.hist_action, + self.imshow_action, self.show_minmax_action]: + self.add_item_to_menu( + item, + menu=self.context_menu, + section=VariableExplorerContextMenuSections.View, + ) + + for item in [resize_rows_action, resize_columns_action]: + self.add_item_to_menu( + item, + menu=self.context_menu, + section=VariableExplorerContextMenuSections.Resize, + ) + + # ---- Context menu when the variable explorer is empty + self.empty_context_menu = self.create_menu( + VariableExplorerWidgetMenus.EmptyContextMenu) + for item in [self.insert_action, self.paste_action]: + self.add_item_to_menu( + item, + menu=self.empty_context_menu, + section=VariableExplorerContextMenuSections.Edit, + ) + + def update_actions(self): + action = self.get_action(VariableExplorerWidgetActions.ToggleMinMax) + action.setEnabled(is_module_installed('numpy')) + nsb = self.current_widget() + + for __, action in self.get_actions().items(): + if action: + # IMPORTANT: Since we are defining the main actions in here + # and the context is WidgetWithChildrenShortcut we need to + # assign the same actions to the children widgets in order + # for shortcuts to work + if nsb: + save_data_action = self.get_action( + VariableExplorerWidgetActions.SaveData) + save_data_action.setEnabled(nsb.filename is not None) + + nsb_actions = nsb.actions() + if action not in nsb_actions: + nsb.addAction(action) + + @on_conf_change + def on_section_conf_change(self, section): + for index in range(self.count()): + widget = self._stack.widget(index) + if widget: + widget.setup() + + # ---- Stack accesors + # ------------------------------------------------------------------------ + def update_finder(self, nsb, old_nsb): + """Initialize or update finder widget.""" + if self.finder is None: + # Initialize finder/search related widgets + self.finder = QWidget(self) + self.text_finder = NamespacesBrowserFinder( + nsb.editor, + callback=nsb.editor.set_regex, + main=nsb, + regex_base=VALID_VARIABLE_CHARS) + self.finder.text_finder = self.text_finder + self.finder_close_button = self.create_toolbutton( + 'close_finder', + triggered=self.hide_finder, + icon=self.create_icon('DialogCloseButton'), + ) + + finder_layout = QHBoxLayout() + finder_layout.addWidget(self.finder_close_button) + finder_layout.addWidget(self.text_finder) + finder_layout.setContentsMargins(0, 0, 0, 0) + self.finder.setLayout(finder_layout) + + layout = self.layout() + layout.addSpacing(1) + layout.addWidget(self.finder) + else: + # Just update references to the same text_finder (Custom QLineEdit) + # widget to the new current NamespaceBrowser and save current + # finder state in the previous NamespaceBrowser + if old_nsb is not None: + self.save_finder_state(old_nsb) + self.text_finder.update_parent( + nsb.editor, + callback=nsb.editor.set_regex, + main=nsb, + ) + + def switch_widget(self, nsb, old_nsb): + """ + Set the current NamespaceBrowser. + + This also setup the finder widget to work with the current + NamespaceBrowser. + """ + self.update_finder(nsb, old_nsb) + finder_visible = nsb.set_text_finder(self.text_finder) + self.finder.setVisible(finder_visible) + search_action = self.get_action(VariableExplorerWidgetActions.Search) + search_action.setChecked(finder_visible) + + # ---- Public API + # ------------------------------------------------------------------------ + + def create_new_widget(self, shellwidget): + nsb = NamespaceBrowser(self) + nsb.set_shellwidget(shellwidget) + nsb.setup() + nsb.sig_free_memory_requested.connect( + self.free_memory) + nsb.sig_start_spinner_requested.connect( + self.start_spinner) + nsb.sig_stop_spinner_requested.connect( + self.stop_spinner) + nsb.sig_hide_finder_requested.connect( + self.hide_finder) + self._set_actions_and_menus(nsb) + return nsb + + def close_widget(self, nsb): + nsb.close() + + def import_data(self, filenames=None): + """ + Import data in current namespace. + """ + if self.count(): + nsb = self.current_widget() + nsb.refresh_table() + nsb.import_data(filenames=filenames) + + def save_data(self): + if self.count(): + nsb = self.current_widget() + nsb.save_data() + self.update_actions() + + def reset_namespace(self): + if self.count(): + nsb = self.current_widget() + nsb.reset_namespace() + + @Slot(bool) + def show_finder(self, checked): + if self.count(): + nsb = self.current_widget() + if checked: + self.finder.text_finder.setText(nsb.last_find) + else: + self.save_finder_state(nsb) + self.finder.text_finder.setText('') + self.finder.setVisible(checked) + if self.finder.isVisible(): + self.finder.text_finder.setFocus() + else: + nsb.editor.setFocus() + + @Slot() + def hide_finder(self): + action = self.get_action(VariableExplorerWidgetActions.Search) + action.setChecked(False) + nsb = self.current_widget() + self.save_finder_state(nsb) + self.finder.text_finder.setText('') + + def save_finder_state(self, nsb): + """ + Save finder state (last input text and visibility). + + The values are saved in the given NamespaceBrowser. + """ + last_find = self.text_finder.text() + finder_visibility = self.finder.isVisible() + nsb.save_finder_state(last_find, finder_visibility) + + def refresh_table(self): + if self.count(): + nsb = self.current_widget() + nsb.refresh_table() + + @Slot() + def free_memory(self): + """ + Free memory signal. + """ + self.sig_free_memory_requested.emit() + QTimer.singleShot(self.INITIAL_FREE_MEMORY_TIME_TRIGGER, + self.sig_free_memory_requested) + QTimer.singleShot(self.SECONDARY_FREE_MEMORY_TIME_TRIGGER, + self.sig_free_memory_requested) + + def resize_rows(self): + self._current_editor.resizeRowsToContents() + + def resize_columns(self): + self._current_editor.resize_column_contents() + + def paste(self): + self._current_editor.paste() + + def copy(self): + self._current_editor.copy() + + def edit_item(self): + self._current_editor.edit_item() + + def plot_item(self): + self._current_editor.plot_item('plot') + + def histogram_item(self): + self._current_editor.plot_item('hist') + + def imshow_item(self): + self._current_editor.imshow_item() + + def save_array(self): + self._current_editor.save_array() + + def insert_item(self): + self._current_editor.insert_item(below=False) + + def remove_item(self): + self._current_editor.remove_item() + + def rename_item(self): + self._current_editor.rename_item() + + def duplicate_item(self): + self._current_editor.duplicate_item() + + def view_item(self): + self._current_editor.view_item() + + # ---- Private API + # ------------------------------------------------------------------------ + @property + def _current_editor(self): + editor = None + if self.count(): + nsb = self.current_widget() + editor = nsb.editor + return editor + + def _set_actions_and_menus(self, nsb): + """ + Set actions and menus created here and used by the namespace + browser editor. + + Although this is not ideal, it's necessary to be able to use + the CollectionsEditor widget separately from this plugin. + """ + editor = nsb.editor + + # Actions + editor.paste_action = self.paste_action + editor.copy_action = self.copy_action + editor.edit_action = self.edit_action + editor.plot_action = self.plot_action + editor.hist_action = self.hist_action + editor.imshow_action = self.imshow_action + editor.save_array_action = self.save_array_action + editor.insert_action = self.insert_action + editor.remove_action = self.remove_action + editor.minmax_action = self.show_minmax_action + editor.rename_action = self.rename_action + editor.duplicate_action = self.duplicate_action + editor.view_action = self.view_action + + # Menus + editor.menu = self.context_menu + editor.empty_ws_menu = self.empty_context_menu + + # These actions are not used for dictionaries (so we don't need them + # for namespaces) but we have to create them so they can be used in + # several places in CollectionsEditor. + editor.insert_action_above = QAction() + editor.insert_action_below = QAction() diff --git a/spyder/plugins/variableexplorer/widgets/namespacebrowser.py b/spyder/plugins/variableexplorer/widgets/namespacebrowser.py index ecfae98a9c5..e45eee5fd93 100644 --- a/spyder/plugins/variableexplorer/widgets/namespacebrowser.py +++ b/spyder/plugins/variableexplorer/widgets/namespacebrowser.py @@ -1,321 +1,321 @@ -# -*- coding: utf-8 -*- -# -# Copyright © Spyder Project Contributors -# Licensed under the terms of the MIT License -# (see spyder/__init__.py for details) - -""" -Namespace browser widget. - -This is the main widget used in the Variable Explorer plugin -""" - -# Standard library imports -import os -import os.path as osp - -# Third library imports -from qtpy import PYQT5 -from qtpy.compat import getopenfilenames, getsavefilename -from qtpy.QtCore import Qt, Signal, Slot -from qtpy.QtGui import QCursor -from qtpy.QtWidgets import (QApplication, QHBoxLayout, QInputDialog, - QMessageBox, QVBoxLayout, QWidget) -from spyder_kernels.utils.iofuncs import iofunctions -from spyder_kernels.utils.misc import fix_reference_name -from spyder_kernels.utils.nsview import REMOTE_SETTINGS - -# Local imports -from spyder.api.translations import get_translation -from spyder.api.widgets.mixins import SpyderWidgetMixin -from spyder.widgets.collectionseditor import RemoteCollectionsEditorTableView -from spyder.plugins.variableexplorer.widgets.importwizard import ImportWizard -from spyder.utils import encoding -from spyder.utils.misc import getcwd_or_home, remove_backslashes -from spyder.widgets.helperwidgets import FinderLineEdit - - -# Localization -_ = get_translation('spyder') - -# Constants -VALID_VARIABLE_CHARS = r"[^\w+*=¡!¿?'\"#$%&()/<>\-\[\]{}^`´;,|¬]*\w" - - -class NamespaceBrowser(QWidget, SpyderWidgetMixin): - """ - Namespace browser (global variables explorer widget). - """ - # This is necessary to test the widget separately from its plugin - CONF_SECTION = 'variable_explorer' - - # Signals - sig_free_memory_requested = Signal() - sig_start_spinner_requested = Signal() - sig_stop_spinner_requested = Signal() - sig_hide_finder_requested = Signal() - - def __init__(self, parent): - if PYQT5: - super().__init__(parent=parent, class_parent=parent) - else: - QWidget.__init__(self, parent) - SpyderWidgetMixin.__init__(self, class_parent=parent) - - # Attributes - self.filename = None - self.text_finder = None - self.last_find = '' - self.finder_is_visible = False - - # Widgets - self.editor = None - self.shellwidget = None - - def setup(self): - """ - Setup the namespace browser with provided options. - """ - assert self.shellwidget is not None - - if self.editor is not None: - self.shellwidget.set_namespace_view_settings() - self.refresh_table() - else: - # Widgets - self.editor = RemoteCollectionsEditorTableView( - self, - data=None, - shellwidget=self.shellwidget, - create_menu=False, - ) - - # Signals - self.editor.sig_files_dropped.connect(self.import_data) - self.editor.sig_free_memory_requested.connect( - self.sig_free_memory_requested) - self.editor.sig_editor_creation_started.connect( - self.sig_start_spinner_requested) - self.editor.sig_editor_shown.connect( - self.sig_stop_spinner_requested) - - # Layout - layout = QVBoxLayout() - layout.setContentsMargins(0, 0, 0, 0) - layout.addWidget(self.editor) - self.setLayout(layout) - - def get_view_settings(self): - """Return dict editor view settings""" - settings = {} - for name in REMOTE_SETTINGS: - settings[name] = self.get_conf(name) - - return settings - - def set_shellwidget(self, shellwidget): - """Bind shellwidget instance to namespace browser""" - self.shellwidget = shellwidget - shellwidget.set_namespacebrowser(self) - - def set_text_finder(self, text_finder): - """Bind NamespaceBrowsersFinder to namespace browser.""" - self.text_finder = text_finder - if self.finder_is_visible: - self.text_finder.setText(self.last_find) - self.editor.finder = text_finder - - return self.finder_is_visible - - def save_finder_state(self, last_find, finder_visibility): - """Save last finder/search text input and finder visibility.""" - if last_find and finder_visibility: - self.last_find = last_find - self.finder_is_visible = finder_visibility - - def refresh_table(self): - """Refresh variable table.""" - self.shellwidget.refresh_namespacebrowser() - try: - self.editor.resizeRowToContents() - except TypeError: - pass - - def process_remote_view(self, remote_view): - """Process remote view""" - # To load all variables when a new filtering search is - # started. - self.text_finder.load_all = False - - if remote_view is not None: - self.set_data(remote_view) - - def set_var_properties(self, properties): - """Set properties of variables""" - if properties is not None: - self.editor.var_properties = properties - - def set_data(self, data): - """Set data.""" - if data != self.editor.source_model.get_data(): - self.editor.set_data(data) - self.editor.adjust_columns() - - @Slot(list) - def import_data(self, filenames=None): - """Import data from text file.""" - title = _("Import data") - if filenames is None: - if self.filename is None: - basedir = getcwd_or_home() - else: - basedir = osp.dirname(self.filename) - filenames, _selfilter = getopenfilenames(self, title, basedir, - iofunctions.load_filters) - if not filenames: - return - elif isinstance(filenames, str): - filenames = [filenames] - - for filename in filenames: - self.filename = str(filename) - if os.name == "nt": - self.filename = remove_backslashes(self.filename) - extension = osp.splitext(self.filename)[1].lower() - - if extension not in iofunctions.load_funcs: - buttons = QMessageBox.Yes | QMessageBox.Cancel - answer = QMessageBox.question(self, title, - _("Unsupported file extension '%s'

    " - "Would you like to import it anyway " - "(by selecting a known file format)?" - ) % extension, buttons) - if answer == QMessageBox.Cancel: - return - formats = list(iofunctions.load_extensions.keys()) - item, ok = QInputDialog.getItem(self, title, - _('Open file as:'), - formats, 0, False) - if ok: - extension = iofunctions.load_extensions[str(item)] - else: - return - - load_func = iofunctions.load_funcs[extension] - - # 'import_wizard' (self.setup_io) - if isinstance(load_func, str): - # Import data with import wizard - error_message = None - try: - text, _encoding = encoding.read(self.filename) - base_name = osp.basename(self.filename) - editor = ImportWizard(self, text, title=base_name, - varname=fix_reference_name(base_name)) - if editor.exec_(): - var_name, clip_data = editor.get_data() - self.editor.new_value(var_name, clip_data) - except Exception as error: - error_message = str(error) - else: - QApplication.setOverrideCursor(QCursor(Qt.WaitCursor)) - QApplication.processEvents() - error_message = self.shellwidget.load_data(self.filename, - extension) - QApplication.restoreOverrideCursor() - QApplication.processEvents() - - if error_message is not None: - QMessageBox.critical(self, title, - _("Unable to load '%s'" - "

    " - "The error message was:
    %s" - ) % (self.filename, error_message)) - self.refresh_table() - - def reset_namespace(self): - warning = self.get_conf( - section='ipython_console', - option='show_reset_namespace_warning' - ) - self.shellwidget.reset_namespace(warning=warning, message=True) - self.editor.automatic_column_width = True - - def save_data(self): - """Save data""" - filename = self.filename - if filename is None: - filename = getcwd_or_home() - extension = osp.splitext(filename)[1].lower() - if not extension: - # Needed to prevent trying to save a data file without extension - # See spyder-ide/spyder#7196 - filename = filename + '.spydata' - filename, _selfilter = getsavefilename(self, _("Save data"), - filename, - iofunctions.save_filters) - if filename: - self.filename = filename - else: - return False - - QApplication.setOverrideCursor(QCursor(Qt.WaitCursor)) - QApplication.processEvents() - - error_message = self.shellwidget.save_namespace(self.filename) - - QApplication.restoreOverrideCursor() - QApplication.processEvents() - if error_message is not None: - if 'Some objects could not be saved:' in error_message: - save_data_message = ( - _("Some objects could not be saved:") - + "

    {obj_list}".format( - obj_list=error_message.split(': ')[1])) - else: - save_data_message = _( - "Unable to save current workspace" - "

    " - "The error message was:
    ") + error_message - - QMessageBox.critical(self, _("Save data"), save_data_message) - - -class NamespacesBrowserFinder(FinderLineEdit): - """Textbox for filtering listed variables in the table.""" - # To load all variables when filtering. - load_all = False - - def update_parent(self, parent, callback=None, main=None): - self._parent = parent - self.main = main - try: - self.textChanged.disconnect() - except TypeError: - pass - if callback: - self.textChanged.connect(callback) - - def load_all_variables(self): - """Load all variables to correctly filter them.""" - if not self.load_all: - self._parent.parent().editor.source_model.load_all() - self.load_all = True - - def keyPressEvent(self, event): - """Qt and FilterLineEdit Override.""" - key = event.key() - if key in [Qt.Key_Up]: - self.load_all_variables() - self._parent.previous_row() - elif key in [Qt.Key_Down]: - self.load_all_variables() - self._parent.next_row() - elif key in [Qt.Key_Escape]: - self.main.sig_hide_finder_requested.emit() - elif key in [Qt.Key_Enter, Qt.Key_Return]: - # TODO: Check if an editor needs to be shown - pass - else: - self.load_all_variables() - super(NamespacesBrowserFinder, self).keyPressEvent(event) +# -*- coding: utf-8 -*- +# +# Copyright © Spyder Project Contributors +# Licensed under the terms of the MIT License +# (see spyder/__init__.py for details) + +""" +Namespace browser widget. + +This is the main widget used in the Variable Explorer plugin +""" + +# Standard library imports +import os +import os.path as osp + +# Third library imports +from qtpy import PYQT5 +from qtpy.compat import getopenfilenames, getsavefilename +from qtpy.QtCore import Qt, Signal, Slot +from qtpy.QtGui import QCursor +from qtpy.QtWidgets import (QApplication, QHBoxLayout, QInputDialog, + QMessageBox, QVBoxLayout, QWidget) +from spyder_kernels.utils.iofuncs import iofunctions +from spyder_kernels.utils.misc import fix_reference_name +from spyder_kernels.utils.nsview import REMOTE_SETTINGS + +# Local imports +from spyder.api.translations import get_translation +from spyder.api.widgets.mixins import SpyderWidgetMixin +from spyder.widgets.collectionseditor import RemoteCollectionsEditorTableView +from spyder.plugins.variableexplorer.widgets.importwizard import ImportWizard +from spyder.utils import encoding +from spyder.utils.misc import getcwd_or_home, remove_backslashes +from spyder.widgets.helperwidgets import FinderLineEdit + + +# Localization +_ = get_translation('spyder') + +# Constants +VALID_VARIABLE_CHARS = r"[^\w+*=¡!¿?'\"#$%&()/<>\-\[\]{}^`´;,|¬]*\w" + + +class NamespaceBrowser(QWidget, SpyderWidgetMixin): + """ + Namespace browser (global variables explorer widget). + """ + # This is necessary to test the widget separately from its plugin + CONF_SECTION = 'variable_explorer' + + # Signals + sig_free_memory_requested = Signal() + sig_start_spinner_requested = Signal() + sig_stop_spinner_requested = Signal() + sig_hide_finder_requested = Signal() + + def __init__(self, parent): + if PYQT5: + super().__init__(parent=parent, class_parent=parent) + else: + QWidget.__init__(self, parent) + SpyderWidgetMixin.__init__(self, class_parent=parent) + + # Attributes + self.filename = None + self.text_finder = None + self.last_find = '' + self.finder_is_visible = False + + # Widgets + self.editor = None + self.shellwidget = None + + def setup(self): + """ + Setup the namespace browser with provided options. + """ + assert self.shellwidget is not None + + if self.editor is not None: + self.shellwidget.set_namespace_view_settings() + self.refresh_table() + else: + # Widgets + self.editor = RemoteCollectionsEditorTableView( + self, + data=None, + shellwidget=self.shellwidget, + create_menu=False, + ) + + # Signals + self.editor.sig_files_dropped.connect(self.import_data) + self.editor.sig_free_memory_requested.connect( + self.sig_free_memory_requested) + self.editor.sig_editor_creation_started.connect( + self.sig_start_spinner_requested) + self.editor.sig_editor_shown.connect( + self.sig_stop_spinner_requested) + + # Layout + layout = QVBoxLayout() + layout.setContentsMargins(0, 0, 0, 0) + layout.addWidget(self.editor) + self.setLayout(layout) + + def get_view_settings(self): + """Return dict editor view settings""" + settings = {} + for name in REMOTE_SETTINGS: + settings[name] = self.get_conf(name) + + return settings + + def set_shellwidget(self, shellwidget): + """Bind shellwidget instance to namespace browser""" + self.shellwidget = shellwidget + shellwidget.set_namespacebrowser(self) + + def set_text_finder(self, text_finder): + """Bind NamespaceBrowsersFinder to namespace browser.""" + self.text_finder = text_finder + if self.finder_is_visible: + self.text_finder.setText(self.last_find) + self.editor.finder = text_finder + + return self.finder_is_visible + + def save_finder_state(self, last_find, finder_visibility): + """Save last finder/search text input and finder visibility.""" + if last_find and finder_visibility: + self.last_find = last_find + self.finder_is_visible = finder_visibility + + def refresh_table(self): + """Refresh variable table.""" + self.shellwidget.refresh_namespacebrowser() + try: + self.editor.resizeRowToContents() + except TypeError: + pass + + def process_remote_view(self, remote_view): + """Process remote view""" + # To load all variables when a new filtering search is + # started. + self.text_finder.load_all = False + + if remote_view is not None: + self.set_data(remote_view) + + def set_var_properties(self, properties): + """Set properties of variables""" + if properties is not None: + self.editor.var_properties = properties + + def set_data(self, data): + """Set data.""" + if data != self.editor.source_model.get_data(): + self.editor.set_data(data) + self.editor.adjust_columns() + + @Slot(list) + def import_data(self, filenames=None): + """Import data from text file.""" + title = _("Import data") + if filenames is None: + if self.filename is None: + basedir = getcwd_or_home() + else: + basedir = osp.dirname(self.filename) + filenames, _selfilter = getopenfilenames(self, title, basedir, + iofunctions.load_filters) + if not filenames: + return + elif isinstance(filenames, str): + filenames = [filenames] + + for filename in filenames: + self.filename = str(filename) + if os.name == "nt": + self.filename = remove_backslashes(self.filename) + extension = osp.splitext(self.filename)[1].lower() + + if extension not in iofunctions.load_funcs: + buttons = QMessageBox.Yes | QMessageBox.Cancel + answer = QMessageBox.question(self, title, + _("Unsupported file extension '%s'

    " + "Would you like to import it anyway " + "(by selecting a known file format)?" + ) % extension, buttons) + if answer == QMessageBox.Cancel: + return + formats = list(iofunctions.load_extensions.keys()) + item, ok = QInputDialog.getItem(self, title, + _('Open file as:'), + formats, 0, False) + if ok: + extension = iofunctions.load_extensions[str(item)] + else: + return + + load_func = iofunctions.load_funcs[extension] + + # 'import_wizard' (self.setup_io) + if isinstance(load_func, str): + # Import data with import wizard + error_message = None + try: + text, _encoding = encoding.read(self.filename) + base_name = osp.basename(self.filename) + editor = ImportWizard(self, text, title=base_name, + varname=fix_reference_name(base_name)) + if editor.exec_(): + var_name, clip_data = editor.get_data() + self.editor.new_value(var_name, clip_data) + except Exception as error: + error_message = str(error) + else: + QApplication.setOverrideCursor(QCursor(Qt.WaitCursor)) + QApplication.processEvents() + error_message = self.shellwidget.load_data(self.filename, + extension) + QApplication.restoreOverrideCursor() + QApplication.processEvents() + + if error_message is not None: + QMessageBox.critical(self, title, + _("Unable to load '%s'" + "

    " + "The error message was:
    %s" + ) % (self.filename, error_message)) + self.refresh_table() + + def reset_namespace(self): + warning = self.get_conf( + section='ipython_console', + option='show_reset_namespace_warning' + ) + self.shellwidget.reset_namespace(warning=warning, message=True) + self.editor.automatic_column_width = True + + def save_data(self): + """Save data""" + filename = self.filename + if filename is None: + filename = getcwd_or_home() + extension = osp.splitext(filename)[1].lower() + if not extension: + # Needed to prevent trying to save a data file without extension + # See spyder-ide/spyder#7196 + filename = filename + '.spydata' + filename, _selfilter = getsavefilename(self, _("Save data"), + filename, + iofunctions.save_filters) + if filename: + self.filename = filename + else: + return False + + QApplication.setOverrideCursor(QCursor(Qt.WaitCursor)) + QApplication.processEvents() + + error_message = self.shellwidget.save_namespace(self.filename) + + QApplication.restoreOverrideCursor() + QApplication.processEvents() + if error_message is not None: + if 'Some objects could not be saved:' in error_message: + save_data_message = ( + _("Some objects could not be saved:") + + "

    {obj_list}".format( + obj_list=error_message.split(': ')[1])) + else: + save_data_message = _( + "Unable to save current workspace" + "

    " + "The error message was:
    ") + error_message + + QMessageBox.critical(self, _("Save data"), save_data_message) + + +class NamespacesBrowserFinder(FinderLineEdit): + """Textbox for filtering listed variables in the table.""" + # To load all variables when filtering. + load_all = False + + def update_parent(self, parent, callback=None, main=None): + self._parent = parent + self.main = main + try: + self.textChanged.disconnect() + except TypeError: + pass + if callback: + self.textChanged.connect(callback) + + def load_all_variables(self): + """Load all variables to correctly filter them.""" + if not self.load_all: + self._parent.parent().editor.source_model.load_all() + self.load_all = True + + def keyPressEvent(self, event): + """Qt and FilterLineEdit Override.""" + key = event.key() + if key in [Qt.Key_Up]: + self.load_all_variables() + self._parent.previous_row() + elif key in [Qt.Key_Down]: + self.load_all_variables() + self._parent.next_row() + elif key in [Qt.Key_Escape]: + self.main.sig_hide_finder_requested.emit() + elif key in [Qt.Key_Enter, Qt.Key_Return]: + # TODO: Check if an editor needs to be shown + pass + else: + self.load_all_variables() + super(NamespacesBrowserFinder, self).keyPressEvent(event) diff --git a/spyder/plugins/variableexplorer/widgets/objecteditor.py b/spyder/plugins/variableexplorer/widgets/objecteditor.py index cc2747c8d1c..5fc885103b8 100644 --- a/spyder/plugins/variableexplorer/widgets/objecteditor.py +++ b/spyder/plugins/variableexplorer/widgets/objecteditor.py @@ -1,175 +1,175 @@ -# -*- coding: utf-8 -*- -# -# Copyright © Spyder Project Contributors -# Licensed under the terms of the MIT License -# (see spyder/__init__.py for details) - -""" -Generic object editor dialog -""" - -# Standard library imports -import datetime - -# Third party imports -from qtpy.QtCore import QObject -from spyder_kernels.utils.lazymodules import ( - FakeObject, numpy as np, pandas as pd, PIL) -from spyder_kernels.utils.nsview import is_known_type - -# Local imports -from spyder.py3compat import is_text_string -from spyder.plugins.variableexplorer.widgets.arrayeditor import ArrayEditor -from spyder.plugins.variableexplorer.widgets.dataframeeditor import ( - DataFrameEditor) -from spyder.plugins.variableexplorer.widgets.texteditor import TextEditor -from spyder.widgets.collectionseditor import CollectionsEditor - - -class DialogKeeper(QObject): - def __init__(self): - QObject.__init__(self) - self.dialogs = {} - self.namespace = None - - def set_namespace(self, namespace): - self.namespace = namespace - - def create_dialog(self, dialog, refname, func): - self.dialogs[id(dialog)] = dialog, refname, func - dialog.accepted.connect( - lambda eid=id(dialog): self.editor_accepted(eid)) - dialog.rejected.connect( - lambda eid=id(dialog): self.editor_rejected(eid)) - dialog.show() - dialog.activateWindow() - dialog.raise_() - - def editor_accepted(self, dialog_id): - dialog, refname, func = self.dialogs[dialog_id] - self.namespace[refname] = func(dialog) - self.dialogs.pop(dialog_id) - - def editor_rejected(self, dialog_id): - self.dialogs.pop(dialog_id) - -keeper = DialogKeeper() - - -def create_dialog(obj, obj_name): - """Creates the editor dialog and returns a tuple (dialog, func) where func - is the function to be called with the dialog instance as argument, after - quitting the dialog box - - The role of this intermediate function is to allow easy monkey-patching. - (uschmitt suggested this indirection here so that he can monkey patch - oedit to show eMZed related data) - """ - # Local import - conv_func = lambda data: data - readonly = not is_known_type(obj) - if isinstance(obj, np.ndarray) and np.ndarray is not FakeObject: - dialog = ArrayEditor() - if not dialog.setup_and_check(obj, title=obj_name, - readonly=readonly): - return - elif (isinstance(obj, PIL.Image.Image) and PIL.Image is not FakeObject - and np.ndarray is not FakeObject): - dialog = ArrayEditor() - data = np.array(obj) - if not dialog.setup_and_check(data, title=obj_name, - readonly=readonly): - return - conv_func = lambda data: PIL.Image.fromarray(data, mode=obj.mode) - elif (isinstance(obj, (pd.DataFrame, pd.Series)) and - pd.DataFrame is not FakeObject): - dialog = DataFrameEditor() - if not dialog.setup_and_check(obj): - return - elif is_text_string(obj): - dialog = TextEditor(obj, title=obj_name, readonly=readonly) - else: - dialog = CollectionsEditor() - dialog.setup(obj, title=obj_name, readonly=readonly) - - def end_func(dialog): - return conv_func(dialog.get_value()) - - return dialog, end_func - - -def oedit(obj, modal=True, namespace=None, app=None): - """Edit the object 'obj' in a GUI-based editor and return the edited copy - (if Cancel is pressed, return None) - - The object 'obj' is a container - - Supported container types: - dict, list, set, tuple, str/unicode or numpy.array - - (instantiate a new QApplication if necessary, - so it can be called directly from the interpreter) - """ - if modal: - obj_name = '' - else: - assert is_text_string(obj) - obj_name = obj - if namespace is None: - namespace = globals() - keeper.set_namespace(namespace) - obj = namespace[obj_name] - # keep QApplication reference alive in the Python interpreter: - namespace['__qapp__'] = app - - result = create_dialog(obj, obj_name) - if result is None: - return - dialog, end_func = result - - if modal: - if dialog.exec_(): - return end_func(dialog) - else: - keeper.create_dialog(dialog, obj_name, end_func) - import os - if os.name == 'nt' and app: - app.exec_() - - -#============================================================================== -# Tests -#============================================================================== -def test(): - """Run object editor test""" - # Local import - from spyder.utils.qthelpers import qapplication - app = qapplication() # analysis:ignore - - data = np.random.randint(1, 256, size=(100, 100)).astype('uint8') - image = PIL.Image.fromarray(data) - example = {'str': 'kjkj kj k j j kj k jkj', - 'list': [1, 3, 4, 'kjkj', None], - 'set': {1, 2, 1, 3, None, 'A', 'B', 'C', True, False}, - 'dict': {'d': 1, 'a': np.random.rand(10, 10), 'b': [1, 2]}, - 'float': 1.2233, - 'array': np.random.rand(10, 10), - 'image': image, - 'date': datetime.date(1945, 5, 8), - 'datetime': datetime.datetime(1945, 5, 8), - } - image = oedit(image) - class Foobar(object): - def __init__(self): - self.text = "toto" - foobar = Foobar() - - print(oedit(foobar, app=app)) # spyder: test-skip - print(oedit(example, app=app)) # spyder: test-skip - print(oedit(np.random.rand(10, 10), app=app)) # spyder: test-skip - print(oedit(oedit.__doc__, app=app)) # spyder: test-skip - print(example) # spyder: test-skip - - -if __name__ == "__main__": - test() +# -*- coding: utf-8 -*- +# +# Copyright © Spyder Project Contributors +# Licensed under the terms of the MIT License +# (see spyder/__init__.py for details) + +""" +Generic object editor dialog +""" + +# Standard library imports +import datetime + +# Third party imports +from qtpy.QtCore import QObject +from spyder_kernels.utils.lazymodules import ( + FakeObject, numpy as np, pandas as pd, PIL) +from spyder_kernels.utils.nsview import is_known_type + +# Local imports +from spyder.py3compat import is_text_string +from spyder.plugins.variableexplorer.widgets.arrayeditor import ArrayEditor +from spyder.plugins.variableexplorer.widgets.dataframeeditor import ( + DataFrameEditor) +from spyder.plugins.variableexplorer.widgets.texteditor import TextEditor +from spyder.widgets.collectionseditor import CollectionsEditor + + +class DialogKeeper(QObject): + def __init__(self): + QObject.__init__(self) + self.dialogs = {} + self.namespace = None + + def set_namespace(self, namespace): + self.namespace = namespace + + def create_dialog(self, dialog, refname, func): + self.dialogs[id(dialog)] = dialog, refname, func + dialog.accepted.connect( + lambda eid=id(dialog): self.editor_accepted(eid)) + dialog.rejected.connect( + lambda eid=id(dialog): self.editor_rejected(eid)) + dialog.show() + dialog.activateWindow() + dialog.raise_() + + def editor_accepted(self, dialog_id): + dialog, refname, func = self.dialogs[dialog_id] + self.namespace[refname] = func(dialog) + self.dialogs.pop(dialog_id) + + def editor_rejected(self, dialog_id): + self.dialogs.pop(dialog_id) + +keeper = DialogKeeper() + + +def create_dialog(obj, obj_name): + """Creates the editor dialog and returns a tuple (dialog, func) where func + is the function to be called with the dialog instance as argument, after + quitting the dialog box + + The role of this intermediate function is to allow easy monkey-patching. + (uschmitt suggested this indirection here so that he can monkey patch + oedit to show eMZed related data) + """ + # Local import + conv_func = lambda data: data + readonly = not is_known_type(obj) + if isinstance(obj, np.ndarray) and np.ndarray is not FakeObject: + dialog = ArrayEditor() + if not dialog.setup_and_check(obj, title=obj_name, + readonly=readonly): + return + elif (isinstance(obj, PIL.Image.Image) and PIL.Image is not FakeObject + and np.ndarray is not FakeObject): + dialog = ArrayEditor() + data = np.array(obj) + if not dialog.setup_and_check(data, title=obj_name, + readonly=readonly): + return + conv_func = lambda data: PIL.Image.fromarray(data, mode=obj.mode) + elif (isinstance(obj, (pd.DataFrame, pd.Series)) and + pd.DataFrame is not FakeObject): + dialog = DataFrameEditor() + if not dialog.setup_and_check(obj): + return + elif is_text_string(obj): + dialog = TextEditor(obj, title=obj_name, readonly=readonly) + else: + dialog = CollectionsEditor() + dialog.setup(obj, title=obj_name, readonly=readonly) + + def end_func(dialog): + return conv_func(dialog.get_value()) + + return dialog, end_func + + +def oedit(obj, modal=True, namespace=None, app=None): + """Edit the object 'obj' in a GUI-based editor and return the edited copy + (if Cancel is pressed, return None) + + The object 'obj' is a container + + Supported container types: + dict, list, set, tuple, str/unicode or numpy.array + + (instantiate a new QApplication if necessary, + so it can be called directly from the interpreter) + """ + if modal: + obj_name = '' + else: + assert is_text_string(obj) + obj_name = obj + if namespace is None: + namespace = globals() + keeper.set_namespace(namespace) + obj = namespace[obj_name] + # keep QApplication reference alive in the Python interpreter: + namespace['__qapp__'] = app + + result = create_dialog(obj, obj_name) + if result is None: + return + dialog, end_func = result + + if modal: + if dialog.exec_(): + return end_func(dialog) + else: + keeper.create_dialog(dialog, obj_name, end_func) + import os + if os.name == 'nt' and app: + app.exec_() + + +#============================================================================== +# Tests +#============================================================================== +def test(): + """Run object editor test""" + # Local import + from spyder.utils.qthelpers import qapplication + app = qapplication() # analysis:ignore + + data = np.random.randint(1, 256, size=(100, 100)).astype('uint8') + image = PIL.Image.fromarray(data) + example = {'str': 'kjkj kj k j j kj k jkj', + 'list': [1, 3, 4, 'kjkj', None], + 'set': {1, 2, 1, 3, None, 'A', 'B', 'C', True, False}, + 'dict': {'d': 1, 'a': np.random.rand(10, 10), 'b': [1, 2]}, + 'float': 1.2233, + 'array': np.random.rand(10, 10), + 'image': image, + 'date': datetime.date(1945, 5, 8), + 'datetime': datetime.datetime(1945, 5, 8), + } + image = oedit(image) + class Foobar(object): + def __init__(self): + self.text = "toto" + foobar = Foobar() + + print(oedit(foobar, app=app)) # spyder: test-skip + print(oedit(example, app=app)) # spyder: test-skip + print(oedit(np.random.rand(10, 10), app=app)) # spyder: test-skip + print(oedit(oedit.__doc__, app=app)) # spyder: test-skip + print(example) # spyder: test-skip + + +if __name__ == "__main__": + test() diff --git a/spyder/plugins/variableexplorer/widgets/texteditor.py b/spyder/plugins/variableexplorer/widgets/texteditor.py index 2cee81e9017..37e5bc52caa 100644 --- a/spyder/plugins/variableexplorer/widgets/texteditor.py +++ b/spyder/plugins/variableexplorer/widgets/texteditor.py @@ -1,147 +1,147 @@ -# -*- coding: utf-8 -*- -# -# Copyright © Spyder Project Contributors -# Licensed under the terms of the MIT License -# (see spyder/__init__.py for details) - -""" -Text editor dialog -""" - -# Standard library imports -from __future__ import print_function -import sys - -# Third party imports -from qtpy.QtCore import Qt, Slot -from qtpy.QtWidgets import (QDialog, QHBoxLayout, QPushButton, QTextEdit, - QVBoxLayout) - -# Local import -from spyder.config.base import _ -from spyder.config.gui import get_font -from spyder.py3compat import (is_binary_string, to_binary_string, - to_text_string) -from spyder.utils.icon_manager import ima -from spyder.plugins.variableexplorer.widgets.basedialog import BaseDialog - - -class TextEditor(BaseDialog): - """Array Editor Dialog""" - def __init__(self, text, title='', font=None, parent=None, readonly=False): - super().__init__(parent) - - # 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.text = None - self.btn_save_and_close = None - - # Display text as unicode if it comes as bytes, so users see - # its right representation - if is_binary_string(text): - self.is_binary = True - text = to_text_string(text, 'utf8') - else: - self.is_binary = False - - self.layout = QVBoxLayout() - self.setLayout(self.layout) - - # Text edit - self.edit = QTextEdit(parent) - self.edit.setReadOnly(readonly) - self.edit.textChanged.connect(self.text_changed) - self.edit.setPlainText(text) - if font is None: - font = get_font() - self.edit.setFont(font) - self.layout.addWidget(self.edit) - - # Buttons configuration - btn_layout = QHBoxLayout() - btn_layout.addStretch() - if not readonly: - self.btn_save_and_close = QPushButton(_('Save and Close')) - self.btn_save_and_close.setDisabled(True) - self.btn_save_and_close.clicked.connect(self.accept) - btn_layout.addWidget(self.btn_save_and_close) - - self.btn_close = QPushButton(_('Close')) - self.btn_close.setAutoDefault(True) - self.btn_close.setDefault(True) - self.btn_close.clicked.connect(self.reject) - btn_layout.addWidget(self.btn_close) - - self.layout.addLayout(btn_layout) - - # Make the dialog act as a window - if sys.platform == 'darwin': - # See spyder-ide/spyder#12825 - self.setWindowFlags(Qt.Tool) - else: - # Make the dialog act as a window - self.setWindowFlags(Qt.Window) - - self.setWindowIcon(ima.icon('edit')) - if title: - try: - unicode_title = to_text_string(title) - except UnicodeEncodeError: - unicode_title = u'' - else: - unicode_title = u'' - - self.setWindowTitle(_("Text editor") + \ - u"%s" % (u" - " + unicode_title - if unicode_title else u"")) - - @Slot() - def text_changed(self): - """Text has changed""" - # Save text as bytes, if it was initially bytes - if self.is_binary: - self.text = to_binary_string(self.edit.toPlainText(), 'utf8') - else: - self.text = to_text_string(self.edit.toPlainText()) - if self.btn_save_and_close: - self.btn_save_and_close.setEnabled(True) - self.btn_save_and_close.setAutoDefault(True) - self.btn_save_and_close.setDefault(True) - - def get_value(self): - """Return modified text""" - # It is import to avoid accessing Qt C++ object as it has probably - # already been destroyed, due to the Qt.WA_DeleteOnClose attribute - return self.text - - def setup_and_check(self, value): - """Verify if TextEditor is able to display strings passed to it.""" - try: - to_text_string(value, 'utf8') - return True - except: - return False - -#============================================================================== -# Tests -#============================================================================== -def test(): - """Text editor demo""" - from spyder.utils.qthelpers import qapplication - _app = qapplication() # analysis:ignore - - text = """01234567890123456789012345678901234567890123456789012345678901234567890123456789 -dedekdh elkd ezd ekjd lekdj elkdfjelfjk e""" - dialog = TextEditor(text) - dialog.exec_() - - dlg_text = dialog.get_value() - assert text == dlg_text - - -if __name__ == "__main__": - test() +# -*- coding: utf-8 -*- +# +# Copyright © Spyder Project Contributors +# Licensed under the terms of the MIT License +# (see spyder/__init__.py for details) + +""" +Text editor dialog +""" + +# Standard library imports +from __future__ import print_function +import sys + +# Third party imports +from qtpy.QtCore import Qt, Slot +from qtpy.QtWidgets import (QDialog, QHBoxLayout, QPushButton, QTextEdit, + QVBoxLayout) + +# Local import +from spyder.config.base import _ +from spyder.config.gui import get_font +from spyder.py3compat import (is_binary_string, to_binary_string, + to_text_string) +from spyder.utils.icon_manager import ima +from spyder.plugins.variableexplorer.widgets.basedialog import BaseDialog + + +class TextEditor(BaseDialog): + """Array Editor Dialog""" + def __init__(self, text, title='', font=None, parent=None, readonly=False): + super().__init__(parent) + + # 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.text = None + self.btn_save_and_close = None + + # Display text as unicode if it comes as bytes, so users see + # its right representation + if is_binary_string(text): + self.is_binary = True + text = to_text_string(text, 'utf8') + else: + self.is_binary = False + + self.layout = QVBoxLayout() + self.setLayout(self.layout) + + # Text edit + self.edit = QTextEdit(parent) + self.edit.setReadOnly(readonly) + self.edit.textChanged.connect(self.text_changed) + self.edit.setPlainText(text) + if font is None: + font = get_font() + self.edit.setFont(font) + self.layout.addWidget(self.edit) + + # Buttons configuration + btn_layout = QHBoxLayout() + btn_layout.addStretch() + if not readonly: + self.btn_save_and_close = QPushButton(_('Save and Close')) + self.btn_save_and_close.setDisabled(True) + self.btn_save_and_close.clicked.connect(self.accept) + btn_layout.addWidget(self.btn_save_and_close) + + self.btn_close = QPushButton(_('Close')) + self.btn_close.setAutoDefault(True) + self.btn_close.setDefault(True) + self.btn_close.clicked.connect(self.reject) + btn_layout.addWidget(self.btn_close) + + self.layout.addLayout(btn_layout) + + # Make the dialog act as a window + if sys.platform == 'darwin': + # See spyder-ide/spyder#12825 + self.setWindowFlags(Qt.Tool) + else: + # Make the dialog act as a window + self.setWindowFlags(Qt.Window) + + self.setWindowIcon(ima.icon('edit')) + if title: + try: + unicode_title = to_text_string(title) + except UnicodeEncodeError: + unicode_title = u'' + else: + unicode_title = u'' + + self.setWindowTitle(_("Text editor") + \ + u"%s" % (u" - " + unicode_title + if unicode_title else u"")) + + @Slot() + def text_changed(self): + """Text has changed""" + # Save text as bytes, if it was initially bytes + if self.is_binary: + self.text = to_binary_string(self.edit.toPlainText(), 'utf8') + else: + self.text = to_text_string(self.edit.toPlainText()) + if self.btn_save_and_close: + self.btn_save_and_close.setEnabled(True) + self.btn_save_and_close.setAutoDefault(True) + self.btn_save_and_close.setDefault(True) + + def get_value(self): + """Return modified text""" + # It is import to avoid accessing Qt C++ object as it has probably + # already been destroyed, due to the Qt.WA_DeleteOnClose attribute + return self.text + + def setup_and_check(self, value): + """Verify if TextEditor is able to display strings passed to it.""" + try: + to_text_string(value, 'utf8') + return True + except: + return False + +#============================================================================== +# Tests +#============================================================================== +def test(): + """Text editor demo""" + from spyder.utils.qthelpers import qapplication + _app = qapplication() # analysis:ignore + + text = """01234567890123456789012345678901234567890123456789012345678901234567890123456789 +dedekdh elkd ezd ekjd lekdj elkdfjelfjk e""" + dialog = TextEditor(text) + dialog.exec_() + + dlg_text = dialog.get_value() + assert text == dlg_text + + +if __name__ == "__main__": + test() diff --git a/spyder/plugins/workingdirectory/confpage.py b/spyder/plugins/workingdirectory/confpage.py index f4377bba79c..66133682a00 100644 --- a/spyder/plugins/workingdirectory/confpage.py +++ b/spyder/plugins/workingdirectory/confpage.py @@ -1,123 +1,123 @@ -# -*- coding: utf-8 -*- -# -# Copyright © Spyder Project Contributors -# Licensed under the terms of the MIT License -# (see spyder/__init__.py for details) - -"""Working Directory Plugin""" - -# pylint: disable=C0103 -# pylint: disable=R0903 -# pylint: disable=R0911 -# pylint: disable=R0201 - -# Third party imports -from qtpy.QtWidgets import (QButtonGroup, QGroupBox, QHBoxLayout, QLabel, - QVBoxLayout) - -# Local imports -from spyder.config.base import _ -from spyder.api.preferences import PluginConfigPage -from spyder.utils.misc import getcwd_or_home - - -class WorkingDirectoryConfigPage(PluginConfigPage): - - def setup_page(self): - about_label = QLabel( - _("This is the directory that will be set as the default for " - "the IPython console and Files panes.") - ) - about_label.setWordWrap(True) - - # Startup directory - startup_group = QGroupBox(_("Startup")) - startup_bg = QButtonGroup(startup_group) - startup_label = QLabel( - _("At startup, the working directory is:") - ) - startup_label.setWordWrap(True) - lastdir_radio = self.create_radiobutton( - _("The project (if open) or user home directory"), - 'startup/use_project_or_home_directory', - tip=_("The startup working dir will be root of the " - "current project if one is open, otherwise the " - "user home directory"), - button_group=startup_bg - ) - thisdir_radio = self.create_radiobutton( - _("The following directory:"), - 'startup/use_fixed_directory', - _("At startup, the current working directory will be the " - "specified path"), - button_group=startup_bg - ) - thisdir_bd = self.create_browsedir( - "", - 'startup/fixed_directory', - getcwd_or_home() - ) - thisdir_radio.toggled.connect(thisdir_bd.setEnabled) - lastdir_radio.toggled.connect(thisdir_bd.setDisabled) - thisdir_layout = QHBoxLayout() - thisdir_layout.addWidget(thisdir_radio) - thisdir_layout.addWidget(thisdir_bd) - - startup_layout = QVBoxLayout() - startup_layout.addWidget(startup_label) - startup_layout.addWidget(lastdir_radio) - startup_layout.addLayout(thisdir_layout) - startup_group.setLayout(startup_layout) - - # Console Directory - console_group = QGroupBox(_("New consoles")) - console_label = QLabel( - _("The working directory for new IPython consoles is:") - ) - console_label.setWordWrap(True) - console_bg = QButtonGroup(console_group) - console_project_radio = self.create_radiobutton( - _("The project (if open) or user home directory"), - 'console/use_project_or_home_directory', - tip=_("The working dir for new consoles will be root of the " - "project if one is open, otherwise the user home directory"), - button_group=console_bg - ) - console_cwd_radio = self.create_radiobutton( - _("The working directory of the current console"), - 'console/use_cwd', - button_group=console_bg - ) - console_dir_radio = self.create_radiobutton( - _("The following directory:"), - 'console/use_fixed_directory', - _("The directory when a new console is open will be the " - "specified path"), - button_group=console_bg - ) - console_dir_bd = self.create_browsedir( - "", - 'console/fixed_directory', - getcwd_or_home() - ) - console_dir_radio.toggled.connect(console_dir_bd.setEnabled) - console_project_radio.toggled.connect(console_dir_bd.setDisabled) - console_cwd_radio.toggled.connect(console_dir_bd.setDisabled) - console_dir_layout = QHBoxLayout() - console_dir_layout.addWidget(console_dir_radio) - console_dir_layout.addWidget(console_dir_bd) - - console_layout = QVBoxLayout() - console_layout.addWidget(console_label) - console_layout.addWidget(console_project_radio) - console_layout.addWidget(console_cwd_radio) - console_layout.addLayout(console_dir_layout) - console_group.setLayout(console_layout) - - vlayout = QVBoxLayout() - vlayout.addWidget(about_label) - vlayout.addSpacing(10) - vlayout.addWidget(startup_group) - vlayout.addWidget(console_group) - vlayout.addStretch(1) - self.setLayout(vlayout) +# -*- coding: utf-8 -*- +# +# Copyright © Spyder Project Contributors +# Licensed under the terms of the MIT License +# (see spyder/__init__.py for details) + +"""Working Directory Plugin""" + +# pylint: disable=C0103 +# pylint: disable=R0903 +# pylint: disable=R0911 +# pylint: disable=R0201 + +# Third party imports +from qtpy.QtWidgets import (QButtonGroup, QGroupBox, QHBoxLayout, QLabel, + QVBoxLayout) + +# Local imports +from spyder.config.base import _ +from spyder.api.preferences import PluginConfigPage +from spyder.utils.misc import getcwd_or_home + + +class WorkingDirectoryConfigPage(PluginConfigPage): + + def setup_page(self): + about_label = QLabel( + _("This is the directory that will be set as the default for " + "the IPython console and Files panes.") + ) + about_label.setWordWrap(True) + + # Startup directory + startup_group = QGroupBox(_("Startup")) + startup_bg = QButtonGroup(startup_group) + startup_label = QLabel( + _("At startup, the working directory is:") + ) + startup_label.setWordWrap(True) + lastdir_radio = self.create_radiobutton( + _("The project (if open) or user home directory"), + 'startup/use_project_or_home_directory', + tip=_("The startup working dir will be root of the " + "current project if one is open, otherwise the " + "user home directory"), + button_group=startup_bg + ) + thisdir_radio = self.create_radiobutton( + _("The following directory:"), + 'startup/use_fixed_directory', + _("At startup, the current working directory will be the " + "specified path"), + button_group=startup_bg + ) + thisdir_bd = self.create_browsedir( + "", + 'startup/fixed_directory', + getcwd_or_home() + ) + thisdir_radio.toggled.connect(thisdir_bd.setEnabled) + lastdir_radio.toggled.connect(thisdir_bd.setDisabled) + thisdir_layout = QHBoxLayout() + thisdir_layout.addWidget(thisdir_radio) + thisdir_layout.addWidget(thisdir_bd) + + startup_layout = QVBoxLayout() + startup_layout.addWidget(startup_label) + startup_layout.addWidget(lastdir_radio) + startup_layout.addLayout(thisdir_layout) + startup_group.setLayout(startup_layout) + + # Console Directory + console_group = QGroupBox(_("New consoles")) + console_label = QLabel( + _("The working directory for new IPython consoles is:") + ) + console_label.setWordWrap(True) + console_bg = QButtonGroup(console_group) + console_project_radio = self.create_radiobutton( + _("The project (if open) or user home directory"), + 'console/use_project_or_home_directory', + tip=_("The working dir for new consoles will be root of the " + "project if one is open, otherwise the user home directory"), + button_group=console_bg + ) + console_cwd_radio = self.create_radiobutton( + _("The working directory of the current console"), + 'console/use_cwd', + button_group=console_bg + ) + console_dir_radio = self.create_radiobutton( + _("The following directory:"), + 'console/use_fixed_directory', + _("The directory when a new console is open will be the " + "specified path"), + button_group=console_bg + ) + console_dir_bd = self.create_browsedir( + "", + 'console/fixed_directory', + getcwd_or_home() + ) + console_dir_radio.toggled.connect(console_dir_bd.setEnabled) + console_project_radio.toggled.connect(console_dir_bd.setDisabled) + console_cwd_radio.toggled.connect(console_dir_bd.setDisabled) + console_dir_layout = QHBoxLayout() + console_dir_layout.addWidget(console_dir_radio) + console_dir_layout.addWidget(console_dir_bd) + + console_layout = QVBoxLayout() + console_layout.addWidget(console_label) + console_layout.addWidget(console_project_radio) + console_layout.addWidget(console_cwd_radio) + console_layout.addLayout(console_dir_layout) + console_group.setLayout(console_layout) + + vlayout = QVBoxLayout() + vlayout.addWidget(about_label) + vlayout.addSpacing(10) + vlayout.addWidget(startup_group) + vlayout.addWidget(console_group) + vlayout.addStretch(1) + self.setLayout(vlayout) diff --git a/spyder/plugins/workingdirectory/container.py b/spyder/plugins/workingdirectory/container.py index 7787967fbb1..0686cdd67b3 100644 --- a/spyder/plugins/workingdirectory/container.py +++ b/spyder/plugins/workingdirectory/container.py @@ -1,332 +1,332 @@ -# -*- coding: utf-8 -*- -# -# Copyright © Spyder Project Contributors -# Licensed under the terms of the MIT License -# (see spyder/__init__.py for details) - -""" -Working Directory widget. -""" - -# Standard library imports -import logging -import os -import os.path as osp - -# Third party imports -from qtpy.compat import getexistingdirectory -from qtpy.QtCore import QSize, Signal, Slot - -# Local imports -from spyder.api.config.decorators import on_conf_change -from spyder.api.translations import get_translation -from spyder.api.widgets.main_container import PluginMainContainer -from spyder.api.widgets.toolbars import ApplicationToolbar -from spyder.config.base import get_home_dir -from spyder.utils.misc import getcwd_or_home -from spyder.widgets.comboboxes import PathComboBox - - -# Localization and logging -_ = get_translation('spyder') -logger = logging.getLogger(__name__) - - -# ---- Constants -# ---------------------------------------------------------------------------- -class WorkingDirectoryActions: - Previous = 'previous_action' - Next = "next_action" - Browse = "browse_action" - Parent = "parent_action" - - -class WorkingDirectoryToolbarSections: - Main = "main_section" - - -class WorkingDirectoryToolbarItems: - PathComboBox = 'path_combo' - -# ---- Widgets -# ---------------------------------------------------------------------------- -class WorkingDirectoryToolbar(ApplicationToolbar): - ID = 'working_directory_toolbar' - - -class WorkingDirectoryComboBox(PathComboBox): - - def __init__(self, parent, adjust_to_contents=False, id_=None): - super().__init__(parent, adjust_to_contents, id_=id_) - - # Set min width - self.setMinimumWidth(140) - - def sizeHint(self): - """Recommended size when there are toolbars to the right.""" - return QSize(250, 10) - - def enterEvent(self, event): - """Set current path as the tooltip of the widget on hover.""" - self.setToolTip(self.currentText()) - - -# ---- Container -# ---------------------------------------------------------------------------- -class WorkingDirectoryContainer(PluginMainContainer): - """Container for the working directory toolbar.""" - - # Signals - sig_current_directory_changed = Signal(str) - """ - This signal is emitted when the current directory has changed. - - Parameters - ---------- - new_working_directory: str - The new new working directory path. - """ - - # ---- PluginMainContainer API - # ------------------------------------------------------------------------ - def setup(self): - # Variables - self.history = self.get_conf('history', []) - self.histindex = None - - # Widgets - title = _('Current working directory') - self.toolbar = WorkingDirectoryToolbar(self, title) - self.pathedit = WorkingDirectoryComboBox( - self, - adjust_to_contents=self.get_conf('working_dir_adjusttocontents'), - id_=WorkingDirectoryToolbarItems.PathComboBox - ) - - # Widget Setup - self.toolbar.setWindowTitle(title) - self.toolbar.setObjectName(title) - self.pathedit.setMaxCount(self.get_conf('working_dir_history')) - self.pathedit.selected_text = self.pathedit.currentText() - - # Signals - self.pathedit.open_dir.connect(self.chdir) - self.pathedit.activated[str].connect(self.chdir) - - # Actions - self.previous_action = self.create_action( - WorkingDirectoryActions.Previous, - text=_('Back'), - tip=_('Back'), - icon=self.create_icon('previous'), - triggered=self._previous_directory, - ) - self.next_action = self.create_action( - WorkingDirectoryActions.Next, - text=_('Next'), - tip=_('Next'), - icon=self.create_icon('next'), - triggered=self._next_directory, - ) - browse_action = self.create_action( - WorkingDirectoryActions.Browse, - text=_('Browse a working directory'), - tip=_('Browse a working directory'), - icon=self.create_icon('DirOpenIcon'), - triggered=self._select_directory, - ) - parent_action = self.create_action( - WorkingDirectoryActions.Parent, - text=_('Change to parent directory'), - tip=_('Change to parent directory'), - icon=self.create_icon('up'), - triggered=self._parent_directory, - ) - - for item in [self.pathedit, - browse_action, parent_action]: - self.add_item_to_toolbar( - item, - self.toolbar, - section=WorkingDirectoryToolbarSections.Main, - ) - - def update_actions(self): - self.previous_action.setEnabled( - self.histindex is not None and self.histindex > 0) - self.next_action.setEnabled( - self.histindex is not None - and self.histindex < len(self.history) - 1 - ) - - @on_conf_change(option='history') - def on_history_update(self, value): - self.history = value - - # ---- Private API - # ------------------------------------------------------------------------ - def _get_init_workdir(self): - """ - Get the working directory from our config system or return the user - home directory if none can be found. - - Returns - ------- - str: - The initial working directory. - """ - workdir = get_home_dir() - - if self.get_conf('startup/use_project_or_home_directory'): - workdir = get_home_dir() - elif self.get_conf('startup/use_fixed_directory'): - workdir = self.get_conf('startup/fixed_directory') - - # If workdir can't be found, restore default options. - if not osp.isdir(workdir): - self.set_conf('startup/use_project_or_home_directory', True) - self.set_conf('startup/use_fixed_directory', False) - workdir = get_home_dir() - - return workdir - - @Slot() - def _select_directory(self, directory=None): - """ - Select working directory. - - Parameters - ---------- - directory: str, optional - The directory to change to. - - Notes - ----- - If directory is None, a get directory dialog will be used. - """ - if directory is None: - self.sig_redirect_stdio_requested.emit(False) - directory = getexistingdirectory( - self, - _("Select directory"), - getcwd_or_home(), - ) - self.sig_redirect_stdio_requested.emit(True) - - if directory: - self.chdir(directory) - - @Slot() - def _previous_directory(self): - """Select the previous directory.""" - self.histindex -= 1 - self.chdir(directory='', browsing_history=True) - - @Slot() - def _next_directory(self): - """Select the next directory.""" - self.histindex += 1 - self.chdir(directory='', browsing_history=True) - - @Slot() - def _parent_directory(self): - """Change working directory to parent one.""" - self.chdir(osp.join(getcwd_or_home(), osp.pardir)) - - # ---- Public API - # ------------------------------------------------------------------------ - def get_workdir(self): - """ - Get the current working directory. - - Returns - ------- - str: - The current working directory. - """ - return self.pathedit.currentText() - - @Slot(str) - @Slot(str, bool) - @Slot(str, bool, bool) - def chdir(self, directory, browsing_history=False, emit=True): - """ - Set `directory` as working directory. - - Parameters - ---------- - directory: str - The new working directory. - browsing_history: bool, optional - Add the new `directory` to the browsing history. Default is False. - emit: bool, optional - Emit a signal when changing the working directory. - Default is True. - """ - if directory: - directory = osp.abspath(str(directory)) - - # Working directory history management - if browsing_history: - directory = self.history[self.histindex] - elif directory in self.history: - self.histindex = self.history.index(directory) - else: - if self.histindex is None: - self.history = [] - else: - self.history = self.history[:self.histindex + 1] - - self.history.append(directory) - self.histindex = len(self.history) - 1 - - # Changing working directory - try: - logger.debug(f'Setting cwd to {directory}') - os.chdir(directory) - self.pathedit.add_text(directory) - self.update_actions() - - if emit: - self.sig_current_directory_changed.emit(directory) - except OSError: - self.history.pop(self.histindex) - - def get_history(self): - """ - Get the current history list. - - Returns - ------- - list - List of string paths. - """ - return [str(self.pathedit.itemText(index)) for index - in range(self.pathedit.count())] - - def set_history(self, history, cli_workdir=None): - """ - Set the current history list. - - Parameters - ---------- - history: list - List of string paths. - cli_workdir: str or None - Working directory passed on the command line. - """ - self.set_conf('history', history) - if history: - self.pathedit.addItems(history) - - if cli_workdir is None: - workdir = self._get_init_workdir() - else: - logger.debug('Setting cwd passed from the command line') - workdir = cli_workdir - - # In case users pass an invalid directory on the command line - if not osp.isdir(workdir): - workdir = get_home_dir() - - self.chdir(workdir) +# -*- coding: utf-8 -*- +# +# Copyright © Spyder Project Contributors +# Licensed under the terms of the MIT License +# (see spyder/__init__.py for details) + +""" +Working Directory widget. +""" + +# Standard library imports +import logging +import os +import os.path as osp + +# Third party imports +from qtpy.compat import getexistingdirectory +from qtpy.QtCore import QSize, Signal, Slot + +# Local imports +from spyder.api.config.decorators import on_conf_change +from spyder.api.translations import get_translation +from spyder.api.widgets.main_container import PluginMainContainer +from spyder.api.widgets.toolbars import ApplicationToolbar +from spyder.config.base import get_home_dir +from spyder.utils.misc import getcwd_or_home +from spyder.widgets.comboboxes import PathComboBox + + +# Localization and logging +_ = get_translation('spyder') +logger = logging.getLogger(__name__) + + +# ---- Constants +# ---------------------------------------------------------------------------- +class WorkingDirectoryActions: + Previous = 'previous_action' + Next = "next_action" + Browse = "browse_action" + Parent = "parent_action" + + +class WorkingDirectoryToolbarSections: + Main = "main_section" + + +class WorkingDirectoryToolbarItems: + PathComboBox = 'path_combo' + +# ---- Widgets +# ---------------------------------------------------------------------------- +class WorkingDirectoryToolbar(ApplicationToolbar): + ID = 'working_directory_toolbar' + + +class WorkingDirectoryComboBox(PathComboBox): + + def __init__(self, parent, adjust_to_contents=False, id_=None): + super().__init__(parent, adjust_to_contents, id_=id_) + + # Set min width + self.setMinimumWidth(140) + + def sizeHint(self): + """Recommended size when there are toolbars to the right.""" + return QSize(250, 10) + + def enterEvent(self, event): + """Set current path as the tooltip of the widget on hover.""" + self.setToolTip(self.currentText()) + + +# ---- Container +# ---------------------------------------------------------------------------- +class WorkingDirectoryContainer(PluginMainContainer): + """Container for the working directory toolbar.""" + + # Signals + sig_current_directory_changed = Signal(str) + """ + This signal is emitted when the current directory has changed. + + Parameters + ---------- + new_working_directory: str + The new new working directory path. + """ + + # ---- PluginMainContainer API + # ------------------------------------------------------------------------ + def setup(self): + # Variables + self.history = self.get_conf('history', []) + self.histindex = None + + # Widgets + title = _('Current working directory') + self.toolbar = WorkingDirectoryToolbar(self, title) + self.pathedit = WorkingDirectoryComboBox( + self, + adjust_to_contents=self.get_conf('working_dir_adjusttocontents'), + id_=WorkingDirectoryToolbarItems.PathComboBox + ) + + # Widget Setup + self.toolbar.setWindowTitle(title) + self.toolbar.setObjectName(title) + self.pathedit.setMaxCount(self.get_conf('working_dir_history')) + self.pathedit.selected_text = self.pathedit.currentText() + + # Signals + self.pathedit.open_dir.connect(self.chdir) + self.pathedit.activated[str].connect(self.chdir) + + # Actions + self.previous_action = self.create_action( + WorkingDirectoryActions.Previous, + text=_('Back'), + tip=_('Back'), + icon=self.create_icon('previous'), + triggered=self._previous_directory, + ) + self.next_action = self.create_action( + WorkingDirectoryActions.Next, + text=_('Next'), + tip=_('Next'), + icon=self.create_icon('next'), + triggered=self._next_directory, + ) + browse_action = self.create_action( + WorkingDirectoryActions.Browse, + text=_('Browse a working directory'), + tip=_('Browse a working directory'), + icon=self.create_icon('DirOpenIcon'), + triggered=self._select_directory, + ) + parent_action = self.create_action( + WorkingDirectoryActions.Parent, + text=_('Change to parent directory'), + tip=_('Change to parent directory'), + icon=self.create_icon('up'), + triggered=self._parent_directory, + ) + + for item in [self.pathedit, + browse_action, parent_action]: + self.add_item_to_toolbar( + item, + self.toolbar, + section=WorkingDirectoryToolbarSections.Main, + ) + + def update_actions(self): + self.previous_action.setEnabled( + self.histindex is not None and self.histindex > 0) + self.next_action.setEnabled( + self.histindex is not None + and self.histindex < len(self.history) - 1 + ) + + @on_conf_change(option='history') + def on_history_update(self, value): + self.history = value + + # ---- Private API + # ------------------------------------------------------------------------ + def _get_init_workdir(self): + """ + Get the working directory from our config system or return the user + home directory if none can be found. + + Returns + ------- + str: + The initial working directory. + """ + workdir = get_home_dir() + + if self.get_conf('startup/use_project_or_home_directory'): + workdir = get_home_dir() + elif self.get_conf('startup/use_fixed_directory'): + workdir = self.get_conf('startup/fixed_directory') + + # If workdir can't be found, restore default options. + if not osp.isdir(workdir): + self.set_conf('startup/use_project_or_home_directory', True) + self.set_conf('startup/use_fixed_directory', False) + workdir = get_home_dir() + + return workdir + + @Slot() + def _select_directory(self, directory=None): + """ + Select working directory. + + Parameters + ---------- + directory: str, optional + The directory to change to. + + Notes + ----- + If directory is None, a get directory dialog will be used. + """ + if directory is None: + self.sig_redirect_stdio_requested.emit(False) + directory = getexistingdirectory( + self, + _("Select directory"), + getcwd_or_home(), + ) + self.sig_redirect_stdio_requested.emit(True) + + if directory: + self.chdir(directory) + + @Slot() + def _previous_directory(self): + """Select the previous directory.""" + self.histindex -= 1 + self.chdir(directory='', browsing_history=True) + + @Slot() + def _next_directory(self): + """Select the next directory.""" + self.histindex += 1 + self.chdir(directory='', browsing_history=True) + + @Slot() + def _parent_directory(self): + """Change working directory to parent one.""" + self.chdir(osp.join(getcwd_or_home(), osp.pardir)) + + # ---- Public API + # ------------------------------------------------------------------------ + def get_workdir(self): + """ + Get the current working directory. + + Returns + ------- + str: + The current working directory. + """ + return self.pathedit.currentText() + + @Slot(str) + @Slot(str, bool) + @Slot(str, bool, bool) + def chdir(self, directory, browsing_history=False, emit=True): + """ + Set `directory` as working directory. + + Parameters + ---------- + directory: str + The new working directory. + browsing_history: bool, optional + Add the new `directory` to the browsing history. Default is False. + emit: bool, optional + Emit a signal when changing the working directory. + Default is True. + """ + if directory: + directory = osp.abspath(str(directory)) + + # Working directory history management + if browsing_history: + directory = self.history[self.histindex] + elif directory in self.history: + self.histindex = self.history.index(directory) + else: + if self.histindex is None: + self.history = [] + else: + self.history = self.history[:self.histindex + 1] + + self.history.append(directory) + self.histindex = len(self.history) - 1 + + # Changing working directory + try: + logger.debug(f'Setting cwd to {directory}') + os.chdir(directory) + self.pathedit.add_text(directory) + self.update_actions() + + if emit: + self.sig_current_directory_changed.emit(directory) + except OSError: + self.history.pop(self.histindex) + + def get_history(self): + """ + Get the current history list. + + Returns + ------- + list + List of string paths. + """ + return [str(self.pathedit.itemText(index)) for index + in range(self.pathedit.count())] + + def set_history(self, history, cli_workdir=None): + """ + Set the current history list. + + Parameters + ---------- + history: list + List of string paths. + cli_workdir: str or None + Working directory passed on the command line. + """ + self.set_conf('history', history) + if history: + self.pathedit.addItems(history) + + if cli_workdir is None: + workdir = self._get_init_workdir() + else: + logger.debug('Setting cwd passed from the command line') + workdir = cli_workdir + + # In case users pass an invalid directory on the command line + if not osp.isdir(workdir): + workdir = get_home_dir() + + self.chdir(workdir) diff --git a/spyder/plugins/workingdirectory/plugin.py b/spyder/plugins/workingdirectory/plugin.py index a2872458ae2..0b8bc4f9448 100644 --- a/spyder/plugins/workingdirectory/plugin.py +++ b/spyder/plugins/workingdirectory/plugin.py @@ -1,262 +1,262 @@ -# -*- coding: utf-8 -*- -# -# Copyright © Spyder Project Contributors -# Licensed under the terms of the MIT License -# (see spyder/__init__.py for details) - -""" -Working Directory Plugin. -""" - -# Standard library imports -import os.path as osp - -# Third party imports -from qtpy.QtCore import Signal - -# Local imports -from spyder.api.plugins import SpyderPluginV2, Plugins -from spyder.api.plugin_registration.decorators import ( - on_plugin_available, on_plugin_teardown) -from spyder.api.translations import get_translation -from spyder.config.base import get_conf_path -from spyder.plugins.workingdirectory.confpage import WorkingDirectoryConfigPage -from spyder.plugins.workingdirectory.container import ( - WorkingDirectoryContainer) -from spyder.plugins.toolbar.api import ApplicationToolbars -from spyder.utils import encoding - -# Localization -_ = get_translation('spyder') - - -class WorkingDirectory(SpyderPluginV2): - """ - Working directory changer plugin. - """ - - NAME = 'workingdir' - REQUIRES = [Plugins.Preferences, Plugins.Console, Plugins.Toolbar] - OPTIONAL = [Plugins.Editor, Plugins.Explorer, Plugins.IPythonConsole, - Plugins.Find, Plugins.Projects] - CONTAINER_CLASS = WorkingDirectoryContainer - CONF_SECTION = NAME - CONF_WIDGET_CLASS = WorkingDirectoryConfigPage - CAN_BE_DISABLED = False - CONF_FILE = False - LOG_PATH = get_conf_path(CONF_SECTION) - - # --- Signals - # ------------------------------------------------------------------------ - sig_current_directory_changed = Signal(str) - """ - This signal is emitted when the current directory has changed. - - Parameters - ---------- - new_working_directory: str - The new new working directory path. - """ - - # --- SpyderPluginV2 API - # ------------------------------------------------------------------------ - @staticmethod - def get_name(): - return _('Working directory') - - def get_description(self): - return _('Set the current working directory for various plugins.') - - def get_icon(self): - return self.create_icon('DirOpenIcon') - - def on_initialize(self): - container = self.get_container() - - container.sig_current_directory_changed.connect( - self.sig_current_directory_changed) - self.sig_current_directory_changed.connect( - lambda path, plugin=None: self.chdir(path, plugin)) - - cli_options = self.get_command_line_options() - container.set_history( - self.load_history(), - cli_options.working_directory - ) - - @on_plugin_available(plugin=Plugins.Toolbar) - def on_toolbar_available(self): - container = self.get_container() - toolbar = self.get_plugin(Plugins.Toolbar) - toolbar.add_application_toolbar(container.toolbar) - - @on_plugin_available(plugin=Plugins.Preferences) - def on_preferences_available(self): - preferences = self.get_plugin(Plugins.Preferences) - preferences.register_plugin_preferences(self) - - @on_plugin_available(plugin=Plugins.Editor) - def on_editor_available(self): - editor = self.get_plugin(Plugins.Editor) - editor.sig_dir_opened.connect(self._editor_change_dir) - - @on_plugin_available(plugin=Plugins.Explorer) - def on_explorer_available(self): - explorer = self.get_plugin(Plugins.Explorer) - self.sig_current_directory_changed.connect(self._explorer_change_dir) - explorer.sig_dir_opened.connect(self._explorer_dir_opened) - - @on_plugin_available(plugin=Plugins.IPythonConsole) - def on_ipyconsole_available(self): - ipyconsole = self.get_plugin(Plugins.IPythonConsole) - - self.sig_current_directory_changed.connect( - ipyconsole.set_current_client_working_directory) - ipyconsole.sig_current_directory_changed.connect( - self._ipyconsole_change_dir) - - @on_plugin_available(plugin=Plugins.Projects) - def on_projects_available(self): - projects = self.get_plugin(Plugins.Projects) - projects.sig_project_loaded.connect(self._project_loaded) - projects.sig_project_closed[object].connect(self._project_closed) - - @on_plugin_teardown(plugin=Plugins.Toolbar) - def on_toolbar_teardown(self): - toolbar = self.get_plugin(Plugins.Toolbar) - toolbar.remove_application_toolbar( - ApplicationToolbars.WorkingDirectory) - - @on_plugin_teardown(plugin=Plugins.Preferences) - def on_preferences_teardown(self): - preferences = self.get_plugin(Plugins.Preferences) - preferences.deregister_plugin_preferences(self) - - @on_plugin_teardown(plugin=Plugins.Editor) - def on_editor_teardown(self): - editor = self.get_plugin(Plugins.Editor) - editor.sig_dir_opened.disconnect(self._editor_change_dir) - - @on_plugin_teardown(plugin=Plugins.Explorer) - def on_explorer_teardown(self): - explorer = self.get_plugin(Plugins.Explorer) - self.sig_current_directory_changed.disconnect(self._explorer_change_dir) - explorer.sig_dir_opened.disconnect(self._explorer_dir_opened) - - @on_plugin_teardown(plugin=Plugins.IPythonConsole) - def on_ipyconsole_teardown(self): - ipyconsole = self.get_plugin(Plugins.IPythonConsole) - - self.sig_current_directory_changed.disconnect( - ipyconsole.set_current_client_working_directory) - ipyconsole.sig_current_directory_changed.disconnect( - self._ipyconsole_change_dir) - - @on_plugin_teardown(plugin=Plugins.Projects) - def on_projects_teardown(self): - projects = self.get_plugin(Plugins.Projects) - projects.sig_project_loaded.disconnect(self._project_loaded) - projects.sig_project_closed[object].disconnect(self._project_closed) - - # --- Public API - # ------------------------------------------------------------------------ - def chdir(self, directory, sender_plugin=None): - """ - Change current working directory. - - Parameters - ---------- - directory: str - The new working directory to set. - sender_plugin: spyder.api.plugins.SpyderPluginsV2 - The plugin that requested this change: Default is None. - """ - explorer = self.get_plugin(Plugins.Explorer) - ipyconsole = self.get_plugin(Plugins.IPythonConsole) - find = self.get_plugin(Plugins.Find) - - if explorer and sender_plugin != explorer: - explorer.chdir(directory, emit=False) - explorer.refresh(directory, force_current=True) - - if ipyconsole and sender_plugin != ipyconsole: - ipyconsole.set_current_client_working_directory(directory) - - if find: - find.refresh_search_directory() - - if sender_plugin is not None: - container = self.get_container() - container.chdir(directory, emit=False) - - self.save_history() - - def load_history(self, workdir=None): - """ - Load history from a text file located in Spyder configuration folder - or use `workdir` if there are no directories saved yet. - - Parameters - ---------- - workdir: str - The working directory to return. Default is None. - """ - if osp.isfile(self.LOG_PATH): - history, _ = encoding.readlines(self.LOG_PATH) - history = [name for name in history if osp.isdir(name)] - else: - if workdir is None: - workdir = self.get_container()._get_init_workdir() - - history = [workdir] - - return history - - def save_history(self): - """ - Save history to a text file located in Spyder configuration folder. - """ - history = self.get_container().get_history() - try: - encoding.writelines(history, self.LOG_PATH) - except EnvironmentError: - pass - - def get_workdir(self): - """ - Get current working directory. - - Returns - ------- - str - Current working directory. - """ - return self.get_container().get_workdir() - - # -------------------------- Private API ---------------------------------- - def _editor_change_dir(self, path): - editor = self.get_plugin(Plugins.Editor) - self.chdir(path, editor) - - def _explorer_change_dir(self, path): - explorer = self.get_plugin(Plugins.Explorer) - explorer.chdir(path, emit=False) - - def _explorer_dir_opened(self, path): - explorer = self.get_plugin(Plugins.Explorer) - self.chdir(path, explorer) - - def _ipyconsole_change_dir(self, path): - ipyconsole = self.get_plugin(Plugins.IPythonConsole) - self.chdir(path, ipyconsole) - - def _project_loaded(self, path): - projects = self.get_plugin(Plugins.Projects) - self.chdir(directory=path, sender_plugin=projects) - - def _project_closed(self, path): - projects = self.get_plugin(Plugins.Projects) - self.chdir( - directory=projects.get_last_working_dir(), - sender_plugin=projects - ) +# -*- coding: utf-8 -*- +# +# Copyright © Spyder Project Contributors +# Licensed under the terms of the MIT License +# (see spyder/__init__.py for details) + +""" +Working Directory Plugin. +""" + +# Standard library imports +import os.path as osp + +# Third party imports +from qtpy.QtCore import Signal + +# Local imports +from spyder.api.plugins import SpyderPluginV2, Plugins +from spyder.api.plugin_registration.decorators import ( + on_plugin_available, on_plugin_teardown) +from spyder.api.translations import get_translation +from spyder.config.base import get_conf_path +from spyder.plugins.workingdirectory.confpage import WorkingDirectoryConfigPage +from spyder.plugins.workingdirectory.container import ( + WorkingDirectoryContainer) +from spyder.plugins.toolbar.api import ApplicationToolbars +from spyder.utils import encoding + +# Localization +_ = get_translation('spyder') + + +class WorkingDirectory(SpyderPluginV2): + """ + Working directory changer plugin. + """ + + NAME = 'workingdir' + REQUIRES = [Plugins.Preferences, Plugins.Console, Plugins.Toolbar] + OPTIONAL = [Plugins.Editor, Plugins.Explorer, Plugins.IPythonConsole, + Plugins.Find, Plugins.Projects] + CONTAINER_CLASS = WorkingDirectoryContainer + CONF_SECTION = NAME + CONF_WIDGET_CLASS = WorkingDirectoryConfigPage + CAN_BE_DISABLED = False + CONF_FILE = False + LOG_PATH = get_conf_path(CONF_SECTION) + + # --- Signals + # ------------------------------------------------------------------------ + sig_current_directory_changed = Signal(str) + """ + This signal is emitted when the current directory has changed. + + Parameters + ---------- + new_working_directory: str + The new new working directory path. + """ + + # --- SpyderPluginV2 API + # ------------------------------------------------------------------------ + @staticmethod + def get_name(): + return _('Working directory') + + def get_description(self): + return _('Set the current working directory for various plugins.') + + def get_icon(self): + return self.create_icon('DirOpenIcon') + + def on_initialize(self): + container = self.get_container() + + container.sig_current_directory_changed.connect( + self.sig_current_directory_changed) + self.sig_current_directory_changed.connect( + lambda path, plugin=None: self.chdir(path, plugin)) + + cli_options = self.get_command_line_options() + container.set_history( + self.load_history(), + cli_options.working_directory + ) + + @on_plugin_available(plugin=Plugins.Toolbar) + def on_toolbar_available(self): + container = self.get_container() + toolbar = self.get_plugin(Plugins.Toolbar) + toolbar.add_application_toolbar(container.toolbar) + + @on_plugin_available(plugin=Plugins.Preferences) + def on_preferences_available(self): + preferences = self.get_plugin(Plugins.Preferences) + preferences.register_plugin_preferences(self) + + @on_plugin_available(plugin=Plugins.Editor) + def on_editor_available(self): + editor = self.get_plugin(Plugins.Editor) + editor.sig_dir_opened.connect(self._editor_change_dir) + + @on_plugin_available(plugin=Plugins.Explorer) + def on_explorer_available(self): + explorer = self.get_plugin(Plugins.Explorer) + self.sig_current_directory_changed.connect(self._explorer_change_dir) + explorer.sig_dir_opened.connect(self._explorer_dir_opened) + + @on_plugin_available(plugin=Plugins.IPythonConsole) + def on_ipyconsole_available(self): + ipyconsole = self.get_plugin(Plugins.IPythonConsole) + + self.sig_current_directory_changed.connect( + ipyconsole.set_current_client_working_directory) + ipyconsole.sig_current_directory_changed.connect( + self._ipyconsole_change_dir) + + @on_plugin_available(plugin=Plugins.Projects) + def on_projects_available(self): + projects = self.get_plugin(Plugins.Projects) + projects.sig_project_loaded.connect(self._project_loaded) + projects.sig_project_closed[object].connect(self._project_closed) + + @on_plugin_teardown(plugin=Plugins.Toolbar) + def on_toolbar_teardown(self): + toolbar = self.get_plugin(Plugins.Toolbar) + toolbar.remove_application_toolbar( + ApplicationToolbars.WorkingDirectory) + + @on_plugin_teardown(plugin=Plugins.Preferences) + def on_preferences_teardown(self): + preferences = self.get_plugin(Plugins.Preferences) + preferences.deregister_plugin_preferences(self) + + @on_plugin_teardown(plugin=Plugins.Editor) + def on_editor_teardown(self): + editor = self.get_plugin(Plugins.Editor) + editor.sig_dir_opened.disconnect(self._editor_change_dir) + + @on_plugin_teardown(plugin=Plugins.Explorer) + def on_explorer_teardown(self): + explorer = self.get_plugin(Plugins.Explorer) + self.sig_current_directory_changed.disconnect(self._explorer_change_dir) + explorer.sig_dir_opened.disconnect(self._explorer_dir_opened) + + @on_plugin_teardown(plugin=Plugins.IPythonConsole) + def on_ipyconsole_teardown(self): + ipyconsole = self.get_plugin(Plugins.IPythonConsole) + + self.sig_current_directory_changed.disconnect( + ipyconsole.set_current_client_working_directory) + ipyconsole.sig_current_directory_changed.disconnect( + self._ipyconsole_change_dir) + + @on_plugin_teardown(plugin=Plugins.Projects) + def on_projects_teardown(self): + projects = self.get_plugin(Plugins.Projects) + projects.sig_project_loaded.disconnect(self._project_loaded) + projects.sig_project_closed[object].disconnect(self._project_closed) + + # --- Public API + # ------------------------------------------------------------------------ + def chdir(self, directory, sender_plugin=None): + """ + Change current working directory. + + Parameters + ---------- + directory: str + The new working directory to set. + sender_plugin: spyder.api.plugins.SpyderPluginsV2 + The plugin that requested this change: Default is None. + """ + explorer = self.get_plugin(Plugins.Explorer) + ipyconsole = self.get_plugin(Plugins.IPythonConsole) + find = self.get_plugin(Plugins.Find) + + if explorer and sender_plugin != explorer: + explorer.chdir(directory, emit=False) + explorer.refresh(directory, force_current=True) + + if ipyconsole and sender_plugin != ipyconsole: + ipyconsole.set_current_client_working_directory(directory) + + if find: + find.refresh_search_directory() + + if sender_plugin is not None: + container = self.get_container() + container.chdir(directory, emit=False) + + self.save_history() + + def load_history(self, workdir=None): + """ + Load history from a text file located in Spyder configuration folder + or use `workdir` if there are no directories saved yet. + + Parameters + ---------- + workdir: str + The working directory to return. Default is None. + """ + if osp.isfile(self.LOG_PATH): + history, _ = encoding.readlines(self.LOG_PATH) + history = [name for name in history if osp.isdir(name)] + else: + if workdir is None: + workdir = self.get_container()._get_init_workdir() + + history = [workdir] + + return history + + def save_history(self): + """ + Save history to a text file located in Spyder configuration folder. + """ + history = self.get_container().get_history() + try: + encoding.writelines(history, self.LOG_PATH) + except EnvironmentError: + pass + + def get_workdir(self): + """ + Get current working directory. + + Returns + ------- + str + Current working directory. + """ + return self.get_container().get_workdir() + + # -------------------------- Private API ---------------------------------- + def _editor_change_dir(self, path): + editor = self.get_plugin(Plugins.Editor) + self.chdir(path, editor) + + def _explorer_change_dir(self, path): + explorer = self.get_plugin(Plugins.Explorer) + explorer.chdir(path, emit=False) + + def _explorer_dir_opened(self, path): + explorer = self.get_plugin(Plugins.Explorer) + self.chdir(path, explorer) + + def _ipyconsole_change_dir(self, path): + ipyconsole = self.get_plugin(Plugins.IPythonConsole) + self.chdir(path, ipyconsole) + + def _project_loaded(self, path): + projects = self.get_plugin(Plugins.Projects) + self.chdir(directory=path, sender_plugin=projects) + + def _project_closed(self, path): + projects = self.get_plugin(Plugins.Projects) + self.chdir( + directory=projects.get_last_working_dir(), + sender_plugin=projects + ) diff --git a/spyder/py3compat.py b/spyder/py3compat.py index df96c8b8fb0..d93a71cc197 100644 --- a/spyder/py3compat.py +++ b/spyder/py3compat.py @@ -1,319 +1,319 @@ -# -*- coding: utf-8 -*- -# -# Copyright © Spyder Project Contributors -# Licensed under the terms of the MIT License -# (see spyder/__init__.py for details) - -""" -spyder.py3compat ----------------- - -Transitional module providing compatibility functions intended to help -migrating from Python 2 to Python 3. - -This module should be fully compatible with: - * Python >=v2.6 - * Python 3 -""" - -from __future__ import print_function - -import operator -import os -import sys - -PY2 = sys.version[0] == '2' -PY3 = sys.version[0] == '3' -PY36_OR_MORE = sys.version_info[0] >= 3 and sys.version_info[1] >= 6 -PY38_OR_MORE = sys.version_info[0] >= 3 and sys.version_info[1] >= 8 - -#============================================================================== -# Data types -#============================================================================== -if PY2: - # Python 2 - TEXT_TYPES = (str, unicode) - INT_TYPES = (int, long) -else: - # Python 3 - TEXT_TYPES = (str,) - INT_TYPES = (int,) -NUMERIC_TYPES = tuple(list(INT_TYPES) + [float]) - - -#============================================================================== -# Renamed/Reorganized modules -#============================================================================== -if PY2: - # Python 2 - import __builtin__ as builtins - import ConfigParser as configparser - try: - import _winreg as winreg - except ImportError: - pass - from sys import maxint as maxsize - try: - import CStringIO as io - except ImportError: - import StringIO as io - try: - import cPickle as pickle - except ImportError: - import pickle - from UserDict import DictMixin as MutableMapping - from collections import MutableSequence - import thread as _thread - import repr as reprlib - import Queue - from time import clock as perf_counter - from base64 import decodestring as decodebytes -else: - # Python 3 - import builtins - import configparser - try: - import winreg - except ImportError: - pass - from sys import maxsize - import io - import pickle - from collections.abc import MutableMapping, MutableSequence - import _thread - import reprlib - import queue as Queue - from time import perf_counter - from base64 import decodebytes - - -#============================================================================== -# Strings -#============================================================================== -def to_unichr(character_code): - """ - Return the Unicode string of the character with the given Unicode code. - """ - if PY2: - return unichr(character_code) - else: - return chr(character_code) - -def is_type_text_string(obj): - """Return True if `obj` is type text string, False if it is anything else, - like an instance of a class that extends the basestring class.""" - if PY2: - # Python 2 - return type(obj) in [str, unicode] - else: - # Python 3 - return type(obj) in [str, bytes] - -def is_text_string(obj): - """Return True if `obj` is a text string, False if it is anything else, - like binary data (Python 3) or QString (Python 2, PyQt API #1)""" - if PY2: - # Python 2 - return isinstance(obj, basestring) - else: - # Python 3 - return isinstance(obj, str) - -def is_binary_string(obj): - """Return True if `obj` is a binary string, False if it is anything else""" - if PY2: - # Python 2 - return isinstance(obj, str) - else: - # Python 3 - return isinstance(obj, bytes) - -def is_string(obj): - """Return True if `obj` is a text or binary Python string object, - False if it is anything else, like a QString (Python 2, PyQt API #1)""" - return is_text_string(obj) or is_binary_string(obj) - -def is_unicode(obj): - """Return True if `obj` is unicode""" - if PY2: - # Python 2 - return isinstance(obj, unicode) - else: - # Python 3 - return isinstance(obj, str) - -def to_text_string(obj, encoding=None): - """Convert `obj` to (unicode) text string""" - if PY2: - if isinstance(obj, unicode): - return obj - # Python 2 - if encoding is None: - return unicode(obj) - else: - return unicode(obj, encoding) - else: - # Python 3 - if encoding is None: - return str(obj) - elif isinstance(obj, str): - # In case this function is not used properly, this could happen - return obj - else: - return str(obj, encoding) - -def to_binary_string(obj, encoding=None): - """Convert `obj` to binary string (bytes in Python 3, str in Python 2)""" - if PY2: - # Python 2 - if encoding is None: - return str(obj) - else: - return obj.encode(encoding) - else: - # Python 3 - return bytes(obj, 'utf-8' if encoding is None else encoding) - - -#============================================================================== -# Function attributes -#============================================================================== -def get_func_code(func): - """Return function code object""" - if PY2: - # Python 2 - return func.func_code - else: - # Python 3 - return func.__code__ - -def get_func_name(func): - """Return function name""" - if PY2: - # Python 2 - return func.func_name - else: - # Python 3 - return func.__name__ - -def get_func_defaults(func): - """Return function default argument values""" - if PY2: - # Python 2 - return func.func_defaults - else: - # Python 3 - return func.__defaults__ - - -#============================================================================== -# Special method attributes -#============================================================================== -def get_meth_func(obj): - """Return method function object""" - if PY2: - # Python 2 - return obj.im_func - else: - # Python 3 - return obj.__func__ - -def get_meth_class_inst(obj): - """Return method class instance""" - if PY2: - # Python 2 - return obj.im_self - else: - # Python 3 - return obj.__self__ - -def get_meth_class(obj): - """Return method class""" - if PY2: - # Python 2 - return obj.im_class - else: - # Python 3 - return obj.__self__.__class__ - - -#============================================================================== -# Misc. -#============================================================================== -if PY2: - # Python 2 - input = raw_input - getcwd = os.getcwdu - cmp = cmp - import string - str_lower = string.lower - from itertools import izip_longest as zip_longest -else: - # Python 3 - input = input - getcwd = os.getcwd - def cmp(a, b): - return (a > b) - (a < b) - str_lower = str.lower - from itertools import zip_longest - -def qbytearray_to_str(qba): - """Convert QByteArray object to str in a way compatible with Python 2/3""" - return str(bytes(qba.toHex().data()).decode()) - -# ============================================================================= -# Dict funcs -# ============================================================================= -if PY3: - def iterkeys(d, **kw): - return iter(d.keys(**kw)) - - def itervalues(d, **kw): - return iter(d.values(**kw)) - - def iteritems(d, **kw): - return iter(d.items(**kw)) - - def iterlists(d, **kw): - return iter(d.lists(**kw)) - - viewkeys = operator.methodcaller("keys") - - viewvalues = operator.methodcaller("values") - - viewitems = operator.methodcaller("items") -else: - def iterkeys(d, **kw): - return d.iterkeys(**kw) - - def itervalues(d, **kw): - return d.itervalues(**kw) - - def iteritems(d, **kw): - return d.iteritems(**kw) - - def iterlists(d, **kw): - return d.iterlists(**kw) - - viewkeys = operator.methodcaller("viewkeys") - - viewvalues = operator.methodcaller("viewvalues") - - viewitems = operator.methodcaller("viewitems") - - -# ============================================================================ -# Exceptions -# ============================================================================ -if PY2: - ConnectionRefusedError = ConnectionError = BrokenPipeError = OSError - TimeoutError = RuntimeError -else: - ConnectionError = ConnectionError - ConnectionRefusedError = ConnectionRefusedError - TimeoutError = TimeoutError - BrokenPipeError = BrokenPipeError - - -if __name__ == '__main__': - pass +# -*- coding: utf-8 -*- +# +# Copyright © Spyder Project Contributors +# Licensed under the terms of the MIT License +# (see spyder/__init__.py for details) + +""" +spyder.py3compat +---------------- + +Transitional module providing compatibility functions intended to help +migrating from Python 2 to Python 3. + +This module should be fully compatible with: + * Python >=v2.6 + * Python 3 +""" + +from __future__ import print_function + +import operator +import os +import sys + +PY2 = sys.version[0] == '2' +PY3 = sys.version[0] == '3' +PY36_OR_MORE = sys.version_info[0] >= 3 and sys.version_info[1] >= 6 +PY38_OR_MORE = sys.version_info[0] >= 3 and sys.version_info[1] >= 8 + +#============================================================================== +# Data types +#============================================================================== +if PY2: + # Python 2 + TEXT_TYPES = (str, unicode) + INT_TYPES = (int, long) +else: + # Python 3 + TEXT_TYPES = (str,) + INT_TYPES = (int,) +NUMERIC_TYPES = tuple(list(INT_TYPES) + [float]) + + +#============================================================================== +# Renamed/Reorganized modules +#============================================================================== +if PY2: + # Python 2 + import __builtin__ as builtins + import ConfigParser as configparser + try: + import _winreg as winreg + except ImportError: + pass + from sys import maxint as maxsize + try: + import CStringIO as io + except ImportError: + import StringIO as io + try: + import cPickle as pickle + except ImportError: + import pickle + from UserDict import DictMixin as MutableMapping + from collections import MutableSequence + import thread as _thread + import repr as reprlib + import Queue + from time import clock as perf_counter + from base64 import decodestring as decodebytes +else: + # Python 3 + import builtins + import configparser + try: + import winreg + except ImportError: + pass + from sys import maxsize + import io + import pickle + from collections.abc import MutableMapping, MutableSequence + import _thread + import reprlib + import queue as Queue + from time import perf_counter + from base64 import decodebytes + + +#============================================================================== +# Strings +#============================================================================== +def to_unichr(character_code): + """ + Return the Unicode string of the character with the given Unicode code. + """ + if PY2: + return unichr(character_code) + else: + return chr(character_code) + +def is_type_text_string(obj): + """Return True if `obj` is type text string, False if it is anything else, + like an instance of a class that extends the basestring class.""" + if PY2: + # Python 2 + return type(obj) in [str, unicode] + else: + # Python 3 + return type(obj) in [str, bytes] + +def is_text_string(obj): + """Return True if `obj` is a text string, False if it is anything else, + like binary data (Python 3) or QString (Python 2, PyQt API #1)""" + if PY2: + # Python 2 + return isinstance(obj, basestring) + else: + # Python 3 + return isinstance(obj, str) + +def is_binary_string(obj): + """Return True if `obj` is a binary string, False if it is anything else""" + if PY2: + # Python 2 + return isinstance(obj, str) + else: + # Python 3 + return isinstance(obj, bytes) + +def is_string(obj): + """Return True if `obj` is a text or binary Python string object, + False if it is anything else, like a QString (Python 2, PyQt API #1)""" + return is_text_string(obj) or is_binary_string(obj) + +def is_unicode(obj): + """Return True if `obj` is unicode""" + if PY2: + # Python 2 + return isinstance(obj, unicode) + else: + # Python 3 + return isinstance(obj, str) + +def to_text_string(obj, encoding=None): + """Convert `obj` to (unicode) text string""" + if PY2: + if isinstance(obj, unicode): + return obj + # Python 2 + if encoding is None: + return unicode(obj) + else: + return unicode(obj, encoding) + else: + # Python 3 + if encoding is None: + return str(obj) + elif isinstance(obj, str): + # In case this function is not used properly, this could happen + return obj + else: + return str(obj, encoding) + +def to_binary_string(obj, encoding=None): + """Convert `obj` to binary string (bytes in Python 3, str in Python 2)""" + if PY2: + # Python 2 + if encoding is None: + return str(obj) + else: + return obj.encode(encoding) + else: + # Python 3 + return bytes(obj, 'utf-8' if encoding is None else encoding) + + +#============================================================================== +# Function attributes +#============================================================================== +def get_func_code(func): + """Return function code object""" + if PY2: + # Python 2 + return func.func_code + else: + # Python 3 + return func.__code__ + +def get_func_name(func): + """Return function name""" + if PY2: + # Python 2 + return func.func_name + else: + # Python 3 + return func.__name__ + +def get_func_defaults(func): + """Return function default argument values""" + if PY2: + # Python 2 + return func.func_defaults + else: + # Python 3 + return func.__defaults__ + + +#============================================================================== +# Special method attributes +#============================================================================== +def get_meth_func(obj): + """Return method function object""" + if PY2: + # Python 2 + return obj.im_func + else: + # Python 3 + return obj.__func__ + +def get_meth_class_inst(obj): + """Return method class instance""" + if PY2: + # Python 2 + return obj.im_self + else: + # Python 3 + return obj.__self__ + +def get_meth_class(obj): + """Return method class""" + if PY2: + # Python 2 + return obj.im_class + else: + # Python 3 + return obj.__self__.__class__ + + +#============================================================================== +# Misc. +#============================================================================== +if PY2: + # Python 2 + input = raw_input + getcwd = os.getcwdu + cmp = cmp + import string + str_lower = string.lower + from itertools import izip_longest as zip_longest +else: + # Python 3 + input = input + getcwd = os.getcwd + def cmp(a, b): + return (a > b) - (a < b) + str_lower = str.lower + from itertools import zip_longest + +def qbytearray_to_str(qba): + """Convert QByteArray object to str in a way compatible with Python 2/3""" + return str(bytes(qba.toHex().data()).decode()) + +# ============================================================================= +# Dict funcs +# ============================================================================= +if PY3: + def iterkeys(d, **kw): + return iter(d.keys(**kw)) + + def itervalues(d, **kw): + return iter(d.values(**kw)) + + def iteritems(d, **kw): + return iter(d.items(**kw)) + + def iterlists(d, **kw): + return iter(d.lists(**kw)) + + viewkeys = operator.methodcaller("keys") + + viewvalues = operator.methodcaller("values") + + viewitems = operator.methodcaller("items") +else: + def iterkeys(d, **kw): + return d.iterkeys(**kw) + + def itervalues(d, **kw): + return d.itervalues(**kw) + + def iteritems(d, **kw): + return d.iteritems(**kw) + + def iterlists(d, **kw): + return d.iterlists(**kw) + + viewkeys = operator.methodcaller("viewkeys") + + viewvalues = operator.methodcaller("viewvalues") + + viewitems = operator.methodcaller("viewitems") + + +# ============================================================================ +# Exceptions +# ============================================================================ +if PY2: + ConnectionRefusedError = ConnectionError = BrokenPipeError = OSError + TimeoutError = RuntimeError +else: + ConnectionError = ConnectionError + ConnectionRefusedError = ConnectionRefusedError + TimeoutError = TimeoutError + BrokenPipeError = BrokenPipeError + + +if __name__ == '__main__': + pass diff --git a/spyder/requirements.py b/spyder/requirements.py index ecb2b183817..23819f075d7 100644 --- a/spyder/requirements.py +++ b/spyder/requirements.py @@ -1,63 +1,63 @@ -# -*- coding: utf-8 -*- -# -# Copyright © Spyder Project Contributors -# Licensed under the terms of the MIT License -# (see spyder/__init__.py for details) - -"""Module checking Spyder installation requirements""" - -# Standard library imports -import sys -import os.path as osp - -# Third-party imports -from pkg_resources import parse_version - - -def show_warning(message): - """Show warning using Tkinter if available""" - try: - # If tkinter is installed (highly probable), show an error pop-up. - # From https://stackoverflow.com/a/17280890/438386 - import tkinter as tk - root = tk.Tk() - root.title("Spyder") - label = tk.Label(root, text=message, justify='left') - label.pack(side="top", fill="both", expand=True, padx=20, pady=20) - button = tk.Button(root, text="OK", command=lambda: root.destroy()) - button.pack(side="bottom", fill="none", expand=True) - root.mainloop() - except Exception: - pass - raise RuntimeError(message) - - -def check_path(): - """Check sys.path: is Spyder properly installed?""" - dirname = osp.abspath(osp.join(osp.dirname(__file__), osp.pardir)) - if dirname not in sys.path: - show_warning("Spyder must be installed properly " - "(e.g. from source: 'python setup.py install'),\n" - "or directory '%s' must be in PYTHONPATH " - "environment variable." % dirname) - - -def check_qt(): - """Check Qt binding requirements""" - qt_infos = dict(pyqt5=("PyQt5", "5.9"), pyside2=("PySide2", "5.12")) - try: - import qtpy - package_name, required_ver = qt_infos[qtpy.API] - actual_ver = qtpy.QT_VERSION - if (actual_ver is None or - parse_version(actual_ver) < parse_version(required_ver)): - show_warning("Please check Spyder installation requirements:\n" - "%s %s+ is required (found %s)." - % (package_name, required_ver, actual_ver)) - except ImportError: - show_warning("Failed to import qtpy.\n" - "Please check Spyder installation requirements:\n\n" - "qtpy 1.2.0+ and\n" - "%s %s+\n\n" - "are required to run Spyder." - % (qt_infos['pyqt5'])) +# -*- coding: utf-8 -*- +# +# Copyright © Spyder Project Contributors +# Licensed under the terms of the MIT License +# (see spyder/__init__.py for details) + +"""Module checking Spyder installation requirements""" + +# Standard library imports +import sys +import os.path as osp + +# Third-party imports +from pkg_resources import parse_version + + +def show_warning(message): + """Show warning using Tkinter if available""" + try: + # If tkinter is installed (highly probable), show an error pop-up. + # From https://stackoverflow.com/a/17280890/438386 + import tkinter as tk + root = tk.Tk() + root.title("Spyder") + label = tk.Label(root, text=message, justify='left') + label.pack(side="top", fill="both", expand=True, padx=20, pady=20) + button = tk.Button(root, text="OK", command=lambda: root.destroy()) + button.pack(side="bottom", fill="none", expand=True) + root.mainloop() + except Exception: + pass + raise RuntimeError(message) + + +def check_path(): + """Check sys.path: is Spyder properly installed?""" + dirname = osp.abspath(osp.join(osp.dirname(__file__), osp.pardir)) + if dirname not in sys.path: + show_warning("Spyder must be installed properly " + "(e.g. from source: 'python setup.py install'),\n" + "or directory '%s' must be in PYTHONPATH " + "environment variable." % dirname) + + +def check_qt(): + """Check Qt binding requirements""" + qt_infos = dict(pyqt5=("PyQt5", "5.9"), pyside2=("PySide2", "5.12")) + try: + import qtpy + package_name, required_ver = qt_infos[qtpy.API] + actual_ver = qtpy.QT_VERSION + if (actual_ver is None or + parse_version(actual_ver) < parse_version(required_ver)): + show_warning("Please check Spyder installation requirements:\n" + "%s %s+ is required (found %s)." + % (package_name, required_ver, actual_ver)) + except ImportError: + show_warning("Failed to import qtpy.\n" + "Please check Spyder installation requirements:\n\n" + "qtpy 1.2.0+ and\n" + "%s %s+\n\n" + "are required to run Spyder." + % (qt_infos['pyqt5'])) diff --git a/spyder/utils/bsdsocket.py b/spyder/utils/bsdsocket.py index 4d3c1dab954..2d9ef4d023d 100644 --- a/spyder/utils/bsdsocket.py +++ b/spyder/utils/bsdsocket.py @@ -1,183 +1,183 @@ -# -*- coding: utf-8 -*- -# -# Copyright © Spyder Project Contributors -# Licensed under the terms of the MIT License -# (see spyder/__init__.py for details) - -"""BSD socket interface communication utilities""" - -# Be extra careful here. The interface is used to communicate with subprocesses -# by redirecting output streams through a socket. Any exception in this module -# and failure to read out buffers will most likely lock up Spyder. - -import os -import socket -import struct -import threading -import errno -import traceback - -# Local imports -from spyder.config.base import get_debug_level, STDERR -DEBUG_EDITOR = get_debug_level() >= 3 -from spyder.py3compat import pickle -PICKLE_HIGHEST_PROTOCOL = 2 - - -def temp_fail_retry(error, fun, *args): - """Retry to execute function, ignoring EINTR error (interruptions)""" - while 1: - try: - return fun(*args) - except error as e: - eintr = errno.WSAEINTR if os.name == 'nt' else errno.EINTR - if e.args[0] == eintr: - continue - raise - - -SZ = struct.calcsize("l") - - -def write_packet(sock, data, already_pickled=False): - """Write *data* to socket *sock*""" - if already_pickled: - sent_data = data - else: - sent_data = pickle.dumps(data, PICKLE_HIGHEST_PROTOCOL) - sent_data = struct.pack("l", len(sent_data)) + sent_data - nsend = len(sent_data) - while nsend > 0: - nsend -= temp_fail_retry(socket.error, sock.send, sent_data) - - -def read_packet(sock, timeout=None): - """ - Read data from socket *sock* - Returns None if something went wrong - """ - sock.settimeout(timeout) - dlen, data = None, None - try: - if os.name == 'nt': - # Windows implementation - datalen = sock.recv(SZ) - dlen, = struct.unpack("l", datalen) - data = b'' - while len(data) < dlen: - data += sock.recv(dlen) - else: - # Linux/MacOSX implementation - # Thanks to eborisch: - # See spyder-ide/spyder#1106. - datalen = temp_fail_retry(socket.error, sock.recv, - SZ, socket.MSG_WAITALL) - if len(datalen) == SZ: - dlen, = struct.unpack("l", datalen) - data = temp_fail_retry(socket.error, sock.recv, - dlen, socket.MSG_WAITALL) - except socket.timeout: - raise - except socket.error: - data = None - finally: - sock.settimeout(None) - if data is not None: - try: - return pickle.loads(data) - except Exception: - # Catch all exceptions to avoid locking spyder - if DEBUG_EDITOR: - traceback.print_exc(file=STDERR) - return - - -# Using a lock object to avoid communication issues described in -# spyder-ide/spyder#857. -COMMUNICATE_LOCK = threading.Lock() - -# * Old com implementation * -# See solution (1) in spyder-ide/spyder#434, comment 13: -def communicate(sock, command, settings=[]): - """Communicate with monitor""" - try: - COMMUNICATE_LOCK.acquire() - write_packet(sock, command) - for option in settings: - write_packet(sock, option) - return read_packet(sock) - finally: - COMMUNICATE_LOCK.release() - -# new com implementation: -# See solution (2) in spyder-ide/spyder#434, comment 13: -#def communicate(sock, command, settings=[], timeout=None): -# """Communicate with monitor""" -# write_packet(sock, command) -# for option in settings: -# write_packet(sock, option) -# if timeout == 0.: -# # non blocking socket is not really supported: -# # setting timeout to 0. here is equivalent (in current monitor's -# # implementation) to say 'I don't need to receive anything in return' -# return -# while True: -# output = read_packet(sock, timeout=timeout) -# if output is None: -# return -# output_command, output_data = output -# if command == output_command: -# return output_data -# elif DEBUG: -# logging.debug("###### communicate/warning /Begin ######") -# logging.debug("was expecting '%s', received '%s'" \ -# % (command, output_command)) -# logging.debug("###### communicate/warning /End ######") - - -class PacketNotReceived(object): - pass - -PACKET_NOT_RECEIVED = PacketNotReceived() - - -if __name__ == '__main__': - if not os.name == 'nt': - # socket read/write testing - client and server in one thread - - # (techtonik): the stuff below is placed into public domain - print("-- Testing standard Python socket interface --") # spyder: test-skip - - address = ("127.0.0.1", 9999) - - server = socket.socket(socket.AF_INET, socket.SOCK_STREAM) - server.setblocking(0) - server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) - server.bind( address ) - server.listen(2) - - client = socket.socket(socket.AF_INET, socket.SOCK_STREAM) - client.connect( address ) - - client.send("data to be catched".encode('utf-8')) - # accepted server socket is the one we can read from - # note that it is different from server socket - accsock, addr = server.accept() - print('..got "%s" from %s' % (accsock.recv(4096), addr)) # spyder: test-skip - - # accsock.close() - # client.send("more data for recv") - #socket.error: [Errno 9] Bad file descriptor - # accsock, addr = server.accept() - #socket.error: [Errno 11] Resource temporarily unavailable - - - print("-- Testing BSD socket write_packet/read_packet --") # spyder: test-skip - - write_packet(client, "a tiny piece of data") - print('..got "%s" from read_packet()' % (read_packet(accsock))) # spyder: test-skip - - client.close() - server.close() - - print("-- Done.") # spyder: test-skip +# -*- coding: utf-8 -*- +# +# Copyright © Spyder Project Contributors +# Licensed under the terms of the MIT License +# (see spyder/__init__.py for details) + +"""BSD socket interface communication utilities""" + +# Be extra careful here. The interface is used to communicate with subprocesses +# by redirecting output streams through a socket. Any exception in this module +# and failure to read out buffers will most likely lock up Spyder. + +import os +import socket +import struct +import threading +import errno +import traceback + +# Local imports +from spyder.config.base import get_debug_level, STDERR +DEBUG_EDITOR = get_debug_level() >= 3 +from spyder.py3compat import pickle +PICKLE_HIGHEST_PROTOCOL = 2 + + +def temp_fail_retry(error, fun, *args): + """Retry to execute function, ignoring EINTR error (interruptions)""" + while 1: + try: + return fun(*args) + except error as e: + eintr = errno.WSAEINTR if os.name == 'nt' else errno.EINTR + if e.args[0] == eintr: + continue + raise + + +SZ = struct.calcsize("l") + + +def write_packet(sock, data, already_pickled=False): + """Write *data* to socket *sock*""" + if already_pickled: + sent_data = data + else: + sent_data = pickle.dumps(data, PICKLE_HIGHEST_PROTOCOL) + sent_data = struct.pack("l", len(sent_data)) + sent_data + nsend = len(sent_data) + while nsend > 0: + nsend -= temp_fail_retry(socket.error, sock.send, sent_data) + + +def read_packet(sock, timeout=None): + """ + Read data from socket *sock* + Returns None if something went wrong + """ + sock.settimeout(timeout) + dlen, data = None, None + try: + if os.name == 'nt': + # Windows implementation + datalen = sock.recv(SZ) + dlen, = struct.unpack("l", datalen) + data = b'' + while len(data) < dlen: + data += sock.recv(dlen) + else: + # Linux/MacOSX implementation + # Thanks to eborisch: + # See spyder-ide/spyder#1106. + datalen = temp_fail_retry(socket.error, sock.recv, + SZ, socket.MSG_WAITALL) + if len(datalen) == SZ: + dlen, = struct.unpack("l", datalen) + data = temp_fail_retry(socket.error, sock.recv, + dlen, socket.MSG_WAITALL) + except socket.timeout: + raise + except socket.error: + data = None + finally: + sock.settimeout(None) + if data is not None: + try: + return pickle.loads(data) + except Exception: + # Catch all exceptions to avoid locking spyder + if DEBUG_EDITOR: + traceback.print_exc(file=STDERR) + return + + +# Using a lock object to avoid communication issues described in +# spyder-ide/spyder#857. +COMMUNICATE_LOCK = threading.Lock() + +# * Old com implementation * +# See solution (1) in spyder-ide/spyder#434, comment 13: +def communicate(sock, command, settings=[]): + """Communicate with monitor""" + try: + COMMUNICATE_LOCK.acquire() + write_packet(sock, command) + for option in settings: + write_packet(sock, option) + return read_packet(sock) + finally: + COMMUNICATE_LOCK.release() + +# new com implementation: +# See solution (2) in spyder-ide/spyder#434, comment 13: +#def communicate(sock, command, settings=[], timeout=None): +# """Communicate with monitor""" +# write_packet(sock, command) +# for option in settings: +# write_packet(sock, option) +# if timeout == 0.: +# # non blocking socket is not really supported: +# # setting timeout to 0. here is equivalent (in current monitor's +# # implementation) to say 'I don't need to receive anything in return' +# return +# while True: +# output = read_packet(sock, timeout=timeout) +# if output is None: +# return +# output_command, output_data = output +# if command == output_command: +# return output_data +# elif DEBUG: +# logging.debug("###### communicate/warning /Begin ######") +# logging.debug("was expecting '%s', received '%s'" \ +# % (command, output_command)) +# logging.debug("###### communicate/warning /End ######") + + +class PacketNotReceived(object): + pass + +PACKET_NOT_RECEIVED = PacketNotReceived() + + +if __name__ == '__main__': + if not os.name == 'nt': + # socket read/write testing - client and server in one thread + + # (techtonik): the stuff below is placed into public domain + print("-- Testing standard Python socket interface --") # spyder: test-skip + + address = ("127.0.0.1", 9999) + + server = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + server.setblocking(0) + server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + server.bind( address ) + server.listen(2) + + client = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + client.connect( address ) + + client.send("data to be catched".encode('utf-8')) + # accepted server socket is the one we can read from + # note that it is different from server socket + accsock, addr = server.accept() + print('..got "%s" from %s' % (accsock.recv(4096), addr)) # spyder: test-skip + + # accsock.close() + # client.send("more data for recv") + #socket.error: [Errno 9] Bad file descriptor + # accsock, addr = server.accept() + #socket.error: [Errno 11] Resource temporarily unavailable + + + print("-- Testing BSD socket write_packet/read_packet --") # spyder: test-skip + + write_packet(client, "a tiny piece of data") + print('..got "%s" from read_packet()' % (read_packet(accsock))) # spyder: test-skip + + client.close() + server.close() + + print("-- Done.") # spyder: test-skip diff --git a/spyder/utils/conda.py b/spyder/utils/conda.py index 198909612f6..2c43c37dc0e 100644 --- a/spyder/utils/conda.py +++ b/spyder/utils/conda.py @@ -1,164 +1,164 @@ -# -*- coding: utf-8 -*- -# -# Copyright © Spyder Project Contributors -# Licensed under the terms of the MIT License -# (see spyder/__init__.py for details) - -"""Conda/anaconda utilities.""" - -# Standard library imports -import json -import os -import os.path as osp -import sys - -from spyder.utils.programs import find_program, run_program, run_shell_command -from spyder.config.base import get_spyder_umamba_path - -WINDOWS = os.name == 'nt' -CONDA_ENV_LIST_CACHE = {} - - -def add_quotes(path): - """Return quotes if needed for spaces on path.""" - quotes = '"' if ' ' in path and '"' not in path else '' - return '{quotes}{path}{quotes}'.format(quotes=quotes, path=path) - - -def is_conda_env(prefix=None, pyexec=None): - """Check if prefix or python executable are in a conda environment.""" - if pyexec is not None: - pyexec = pyexec.replace('\\', '/') - - if (prefix is None and pyexec is None) or (prefix and pyexec): - raise ValueError('Only `prefix` or `pyexec` should be provided!') - - if pyexec and prefix is None: - prefix = get_conda_env_path(pyexec).replace('\\', '/') - - return os.path.exists(os.path.join(prefix, 'conda-meta')) - - -def get_conda_root_prefix(pyexec=None, quote=False): - """ - Return conda prefix from pyexec path - - If `quote` is True, then quotes are added if spaces are found in the path. - """ - if pyexec is None: - conda_env_prefix = sys.prefix - else: - conda_env_prefix = get_conda_env_path(pyexec) - - conda_env_prefix = conda_env_prefix.replace('\\', '/') - env_key = '/envs/' - - if conda_env_prefix.rfind(env_key) != -1: - root_prefix = conda_env_prefix.split(env_key)[0] - else: - root_prefix = conda_env_prefix - - if quote: - root_prefix = add_quotes(root_prefix) - - return root_prefix - - -def get_conda_activation_script(quote=False): - """ - Return full path to conda activation script. - - If `quote` is True, then quotes are added if spaces are found in the path. - """ - # Use micromamba bundled with Spyder installers or find conda exe - exe = get_spyder_umamba_path() or find_conda() - - if osp.basename(exe).startswith('micromamba'): - # For Micromamba, use the executable - script_path = exe - else: - # Conda activation script is relative to executable - conda_exe_root = osp.dirname(osp.dirname(exe)) - if WINDOWS: - activate = 'Scripts/activate' - else: - activate = 'bin/activate' - script_path = osp.join(conda_exe_root, activate) - - script_path = script_path.replace('\\', '/') - - if quote: - script_path = add_quotes(script_path) - - return script_path - - -def get_conda_env_path(pyexec, quote=False): - """ - Return the full path to the conda environment from give python executable. - - If `quote` is True, then quotes are added if spaces are found in the path. - """ - pyexec = pyexec.replace('\\', '/') - if os.name == 'nt': - conda_env = os.path.dirname(pyexec) - else: - conda_env = os.path.dirname(os.path.dirname(pyexec)) - - if quote: - conda_env = add_quotes(conda_env) - - return conda_env - - -def find_conda(): - """Find conda executable.""" - # First try the environment variables - conda = os.environ.get('CONDA_EXE') or os.environ.get('MAMBA_EXE') - if conda is None: - # Try searching for the executable - conda_exec = 'conda.bat' if WINDOWS else 'conda' - conda = find_program(conda_exec) - return conda - - -def get_list_conda_envs(): - """Return the list of all conda envs found in the system.""" - global CONDA_ENV_LIST_CACHE - - env_list = {} - conda = find_conda() - if conda is None: - return env_list - - cmdstr = ' '.join([conda, 'env', 'list', '--json']) - try: - out, __ = run_shell_command(cmdstr, env={}).communicate() - out = out.decode() - out = json.loads(out) - except Exception: - out = {'envs': []} - - for env in out['envs']: - name = env.split(osp.sep)[-1] - path = osp.join(env, 'python.exe') if WINDOWS else osp.join( - env, 'bin', 'python') - - try: - version, __ = run_program(path, ['--version']).communicate() - version = version.decode() - except Exception: - version = '' - - name = ('base' if name.lower().startswith('anaconda') or - name.lower().startswith('miniconda') else name) - name = 'conda: {}'.format(name) - env_list[name] = (path, version.strip()) - - CONDA_ENV_LIST_CACHE = env_list - return env_list - - -def get_list_conda_envs_cache(): - """Return a cache of envs to avoid computing them again.""" - return CONDA_ENV_LIST_CACHE +# -*- coding: utf-8 -*- +# +# Copyright © Spyder Project Contributors +# Licensed under the terms of the MIT License +# (see spyder/__init__.py for details) + +"""Conda/anaconda utilities.""" + +# Standard library imports +import json +import os +import os.path as osp +import sys + +from spyder.utils.programs import find_program, run_program, run_shell_command +from spyder.config.base import get_spyder_umamba_path + +WINDOWS = os.name == 'nt' +CONDA_ENV_LIST_CACHE = {} + + +def add_quotes(path): + """Return quotes if needed for spaces on path.""" + quotes = '"' if ' ' in path and '"' not in path else '' + return '{quotes}{path}{quotes}'.format(quotes=quotes, path=path) + + +def is_conda_env(prefix=None, pyexec=None): + """Check if prefix or python executable are in a conda environment.""" + if pyexec is not None: + pyexec = pyexec.replace('\\', '/') + + if (prefix is None and pyexec is None) or (prefix and pyexec): + raise ValueError('Only `prefix` or `pyexec` should be provided!') + + if pyexec and prefix is None: + prefix = get_conda_env_path(pyexec).replace('\\', '/') + + return os.path.exists(os.path.join(prefix, 'conda-meta')) + + +def get_conda_root_prefix(pyexec=None, quote=False): + """ + Return conda prefix from pyexec path + + If `quote` is True, then quotes are added if spaces are found in the path. + """ + if pyexec is None: + conda_env_prefix = sys.prefix + else: + conda_env_prefix = get_conda_env_path(pyexec) + + conda_env_prefix = conda_env_prefix.replace('\\', '/') + env_key = '/envs/' + + if conda_env_prefix.rfind(env_key) != -1: + root_prefix = conda_env_prefix.split(env_key)[0] + else: + root_prefix = conda_env_prefix + + if quote: + root_prefix = add_quotes(root_prefix) + + return root_prefix + + +def get_conda_activation_script(quote=False): + """ + Return full path to conda activation script. + + If `quote` is True, then quotes are added if spaces are found in the path. + """ + # Use micromamba bundled with Spyder installers or find conda exe + exe = get_spyder_umamba_path() or find_conda() + + if osp.basename(exe).startswith('micromamba'): + # For Micromamba, use the executable + script_path = exe + else: + # Conda activation script is relative to executable + conda_exe_root = osp.dirname(osp.dirname(exe)) + if WINDOWS: + activate = 'Scripts/activate' + else: + activate = 'bin/activate' + script_path = osp.join(conda_exe_root, activate) + + script_path = script_path.replace('\\', '/') + + if quote: + script_path = add_quotes(script_path) + + return script_path + + +def get_conda_env_path(pyexec, quote=False): + """ + Return the full path to the conda environment from give python executable. + + If `quote` is True, then quotes are added if spaces are found in the path. + """ + pyexec = pyexec.replace('\\', '/') + if os.name == 'nt': + conda_env = os.path.dirname(pyexec) + else: + conda_env = os.path.dirname(os.path.dirname(pyexec)) + + if quote: + conda_env = add_quotes(conda_env) + + return conda_env + + +def find_conda(): + """Find conda executable.""" + # First try the environment variables + conda = os.environ.get('CONDA_EXE') or os.environ.get('MAMBA_EXE') + if conda is None: + # Try searching for the executable + conda_exec = 'conda.bat' if WINDOWS else 'conda' + conda = find_program(conda_exec) + return conda + + +def get_list_conda_envs(): + """Return the list of all conda envs found in the system.""" + global CONDA_ENV_LIST_CACHE + + env_list = {} + conda = find_conda() + if conda is None: + return env_list + + cmdstr = ' '.join([conda, 'env', 'list', '--json']) + try: + out, __ = run_shell_command(cmdstr, env={}).communicate() + out = out.decode() + out = json.loads(out) + except Exception: + out = {'envs': []} + + for env in out['envs']: + name = env.split(osp.sep)[-1] + path = osp.join(env, 'python.exe') if WINDOWS else osp.join( + env, 'bin', 'python') + + try: + version, __ = run_program(path, ['--version']).communicate() + version = version.decode() + except Exception: + version = '' + + name = ('base' if name.lower().startswith('anaconda') or + name.lower().startswith('miniconda') else name) + name = 'conda: {}'.format(name) + env_list[name] = (path, version.strip()) + + CONDA_ENV_LIST_CACHE = env_list + return env_list + + +def get_list_conda_envs_cache(): + """Return a cache of envs to avoid computing them again.""" + return CONDA_ENV_LIST_CACHE diff --git a/spyder/utils/debug.py b/spyder/utils/debug.py index 9a63aabd0c2..655a350dfe7 100644 --- a/spyder/utils/debug.py +++ b/spyder/utils/debug.py @@ -1,146 +1,146 @@ -# -*- coding: utf-8 -*- -# -# Copyright © Spyder Project Contributors -# -# Licensed under the terms of the MIT License -# (see spyder/__init__.py for details) - -""" -Debug utilities that are independent of Spyder code. - -See spyder.config.base for other helpers. -""" - -from __future__ import print_function - -import inspect -import traceback -import time - -from spyder.py3compat import PY2 - - -def log_time(fd): - timestr = "Logging time: %s" % time.ctime(time.time()) - print("="*len(timestr), file=fd) - print(timestr, file=fd) - print("="*len(timestr), file=fd) - print("", file=fd) - -def log_last_error(fname, context=None): - """Log last error in filename *fname* -- *context*: string (optional)""" - fd = open(fname, 'a') - log_time(fd) - if context: - print("Context", file=fd) - print("-------", file=fd) - print("", file=fd) - if PY2: - print(u' '.join(context).encode('utf-8').strip(), file=fd) - else: - print(context, file=fd) - print("", file=fd) - print("Traceback", file=fd) - print("---------", file=fd) - print("", file=fd) - traceback.print_exc(file=fd) - print("", file=fd) - print("", file=fd) - -def log_dt(fname, context, t0): - fd = open(fname, 'a') - log_time(fd) - print("%s: %d ms" % (context, 10*round(1e2*(time.time()-t0))), file=fd) - print("", file=fd) - print("", file=fd) - -def caller_name(skip=2): - """ - Get name of a caller in the format module.class.method - - `skip` specifies how many levels of call stack to skip for caller's name. - skip=1 means "who calls me", skip=2 "who calls my caller" etc. - - An empty string is returned if skipped levels exceed stack height - """ - stack = inspect.stack() - start = 0 + skip - if len(stack) < start + 1: - return '' - parentframe = stack[start][0] - - name = [] - module = inspect.getmodule(parentframe) - # `modname` can be None when frame is executed directly in console - # TODO(techtonik): consider using __main__ - if module: - name.append(module.__name__) - # detect classname - if 'self' in parentframe.f_locals: - # I don't know any way to detect call from the object method - # XXX: there seems to be no way to detect static method call - it will - # be just a function call - name.append(parentframe.f_locals['self'].__class__.__name__) - codename = parentframe.f_code.co_name - if codename != '': # top level usually - name.append( codename ) # function or a method - del parentframe - return ".".join(name) - -def get_class_that_defined(method): - for cls in inspect.getmro(method.im_class): - if method.__name__ in cls.__dict__: - return cls.__name__ - -def log_methods_calls(fname, some_class, prefix=None): - """ - Hack `some_class` to log all method calls into `fname` file. - If `prefix` format is not set, each log entry is prefixed with: - --[ asked / called / defined ] -- - asked - name of `some_class` - called - name of class for which a method is called - defined - name of class where method is defined - - Must be used carefully, because it monkeypatches __getattribute__ call. - - Example: log_methods_calls('log.log', ShellBaseWidget) - """ - # test if file is writable - open(fname, 'a').close() - FILENAME = fname - CLASS = some_class - - PREFIX = "--[ %(asked)s / %(called)s / %(defined)s ]--" - if prefix != None: - PREFIX = prefix - MAXWIDTH = {'o_O': 10} # hack with editable closure dict, to align names - - def format_prefix(method, methodobj): - """ - --[ ShellBase / Internal / BaseEdit ]------- get_position - """ - classnames = { - 'asked': CLASS.__name__, - 'called': methodobj.__class__.__name__, - 'defined': get_class_that_defined(method) - } - line = PREFIX % classnames - MAXWIDTH['o_O'] = max(len(line), MAXWIDTH['o_O']) - return line.ljust(MAXWIDTH['o_O'], '-') - - import types - def __getattribute__(self, name): - attr = object.__getattribute__(self, name) - if type(attr) is not types.MethodType: - return attr - else: - def newfunc(*args, **kwargs): - log = open(FILENAME, 'a') - prefix = format_prefix(attr, self) - log.write('%s %s\n' % (prefix, name)) - log.close() - result = attr(*args, **kwargs) - return result - return newfunc - - some_class.__getattribute__ = __getattribute__ +# -*- coding: utf-8 -*- +# +# Copyright © Spyder Project Contributors +# +# Licensed under the terms of the MIT License +# (see spyder/__init__.py for details) + +""" +Debug utilities that are independent of Spyder code. + +See spyder.config.base for other helpers. +""" + +from __future__ import print_function + +import inspect +import traceback +import time + +from spyder.py3compat import PY2 + + +def log_time(fd): + timestr = "Logging time: %s" % time.ctime(time.time()) + print("="*len(timestr), file=fd) + print(timestr, file=fd) + print("="*len(timestr), file=fd) + print("", file=fd) + +def log_last_error(fname, context=None): + """Log last error in filename *fname* -- *context*: string (optional)""" + fd = open(fname, 'a') + log_time(fd) + if context: + print("Context", file=fd) + print("-------", file=fd) + print("", file=fd) + if PY2: + print(u' '.join(context).encode('utf-8').strip(), file=fd) + else: + print(context, file=fd) + print("", file=fd) + print("Traceback", file=fd) + print("---------", file=fd) + print("", file=fd) + traceback.print_exc(file=fd) + print("", file=fd) + print("", file=fd) + +def log_dt(fname, context, t0): + fd = open(fname, 'a') + log_time(fd) + print("%s: %d ms" % (context, 10*round(1e2*(time.time()-t0))), file=fd) + print("", file=fd) + print("", file=fd) + +def caller_name(skip=2): + """ + Get name of a caller in the format module.class.method + + `skip` specifies how many levels of call stack to skip for caller's name. + skip=1 means "who calls me", skip=2 "who calls my caller" etc. + + An empty string is returned if skipped levels exceed stack height + """ + stack = inspect.stack() + start = 0 + skip + if len(stack) < start + 1: + return '' + parentframe = stack[start][0] + + name = [] + module = inspect.getmodule(parentframe) + # `modname` can be None when frame is executed directly in console + # TODO(techtonik): consider using __main__ + if module: + name.append(module.__name__) + # detect classname + if 'self' in parentframe.f_locals: + # I don't know any way to detect call from the object method + # XXX: there seems to be no way to detect static method call - it will + # be just a function call + name.append(parentframe.f_locals['self'].__class__.__name__) + codename = parentframe.f_code.co_name + if codename != '': # top level usually + name.append( codename ) # function or a method + del parentframe + return ".".join(name) + +def get_class_that_defined(method): + for cls in inspect.getmro(method.im_class): + if method.__name__ in cls.__dict__: + return cls.__name__ + +def log_methods_calls(fname, some_class, prefix=None): + """ + Hack `some_class` to log all method calls into `fname` file. + If `prefix` format is not set, each log entry is prefixed with: + --[ asked / called / defined ] -- + asked - name of `some_class` + called - name of class for which a method is called + defined - name of class where method is defined + + Must be used carefully, because it monkeypatches __getattribute__ call. + + Example: log_methods_calls('log.log', ShellBaseWidget) + """ + # test if file is writable + open(fname, 'a').close() + FILENAME = fname + CLASS = some_class + + PREFIX = "--[ %(asked)s / %(called)s / %(defined)s ]--" + if prefix != None: + PREFIX = prefix + MAXWIDTH = {'o_O': 10} # hack with editable closure dict, to align names + + def format_prefix(method, methodobj): + """ + --[ ShellBase / Internal / BaseEdit ]------- get_position + """ + classnames = { + 'asked': CLASS.__name__, + 'called': methodobj.__class__.__name__, + 'defined': get_class_that_defined(method) + } + line = PREFIX % classnames + MAXWIDTH['o_O'] = max(len(line), MAXWIDTH['o_O']) + return line.ljust(MAXWIDTH['o_O'], '-') + + import types + def __getattribute__(self, name): + attr = object.__getattribute__(self, name) + if type(attr) is not types.MethodType: + return attr + else: + def newfunc(*args, **kwargs): + log = open(FILENAME, 'a') + prefix = format_prefix(attr, self) + log.write('%s %s\n' % (prefix, name)) + log.close() + result = attr(*args, **kwargs) + return result + return newfunc + + some_class.__getattribute__ = __getattribute__ diff --git a/spyder/utils/encoding.py b/spyder/utils/encoding.py index 4bdb74182f8..43a7de9862d 100644 --- a/spyder/utils/encoding.py +++ b/spyder/utils/encoding.py @@ -1,327 +1,327 @@ -# -*- coding: utf-8 -*- -# -# Copyright © Spyder Project Contributors -# Licensed under the terms of the MIT License -# (see spyder/__init__.py for details) - -""" -Text encoding utilities, text file I/O - -Functions 'get_coding', 'decode', 'encode' and 'to_unicode' come from Eric4 -source code (Utilities/__init___.py) Copyright © 2003-2009 Detlev Offenbach -""" - -# Standard library imports -from codecs import BOM_UTF8, BOM_UTF16, BOM_UTF32 -import tempfile -import locale -import re -import os -import os.path as osp -import sys -import time -import errno - -# Third-party imports -from chardet.universaldetector import UniversalDetector -from atomicwrites import atomic_write - -# Local imports -from spyder.py3compat import (is_string, to_text_string, is_binary_string, - is_unicode, PY2) -from spyder.utils.external.binaryornot.check import is_binary - -if PY2: - import pathlib2 as pathlib -else: - import pathlib - - -PREFERRED_ENCODING = locale.getpreferredencoding() - -def transcode(text, input=PREFERRED_ENCODING, output=PREFERRED_ENCODING): - """Transcode a text string""" - try: - return text.decode("cp437").encode("cp1252") - except UnicodeError: - try: - return text.decode("cp437").encode(output) - except UnicodeError: - return text - -#------------------------------------------------------------------------------ -# Functions for encoding and decoding bytes that come from -# the *file system*. -#------------------------------------------------------------------------------ - -# The default encoding for file paths and environment variables should be set -# to match the default encoding that the OS is using. -def getfilesystemencoding(): - """ - Query the filesystem for the encoding used to encode filenames - and environment variables. - """ - encoding = sys.getfilesystemencoding() - if encoding is None: - # Must be Linux or Unix and nl_langinfo(CODESET) failed. - encoding = PREFERRED_ENCODING - return encoding - -FS_ENCODING = getfilesystemencoding() - -def to_unicode_from_fs(string): - """ - Return a unicode version of string decoded using the file system encoding. - """ - if not is_string(string): # string is a QString - string = to_text_string(string.toUtf8(), 'utf-8') - else: - if is_binary_string(string): - try: - unic = string.decode(FS_ENCODING) - except (UnicodeError, TypeError): - pass - else: - return unic - return string - -def to_fs_from_unicode(unic): - """ - Return a byte string version of unic encoded using the file - system encoding. - """ - if is_unicode(unic): - try: - string = unic.encode(FS_ENCODING) - except (UnicodeError, TypeError): - pass - else: - return string - return unic - -#------------------------------------------------------------------------------ -# Functions for encoding and decoding *text data* itself, usually originating -# from or destined for the *contents* of a file. -#------------------------------------------------------------------------------ - -# Codecs for working with files and text. -CODING_RE = re.compile(r"coding[:=]\s*([-\w_.]+)") -CODECS = ['utf-8', 'iso8859-1', 'iso8859-15', 'ascii', 'koi8-r', 'cp1251', - 'koi8-u', 'iso8859-2', 'iso8859-3', 'iso8859-4', 'iso8859-5', - 'iso8859-6', 'iso8859-7', 'iso8859-8', 'iso8859-9', - 'iso8859-10', 'iso8859-13', 'iso8859-14', 'latin-1', - 'utf-16'] - - -def get_coding(text, force_chardet=False): - """ - Function to get the coding of a text. - @param text text to inspect (string) - @return coding string - """ - if not force_chardet: - for line in text.splitlines()[:2]: - try: - result = CODING_RE.search(to_text_string(line)) - except UnicodeDecodeError: - # This could fail because to_text_string assume the text - # is utf8-like and we don't know the encoding to give - # it to to_text_string - pass - else: - if result: - codec = result.group(1) - # sometimes we find a false encoding that can - # result in errors - if codec in CODECS: - return codec - - # Fallback using chardet - if is_binary_string(text): - detector = UniversalDetector() - for line in text.splitlines()[:2]: - detector.feed(line) - if detector.done: break - - detector.close() - return detector.result['encoding'] - - return None - -def decode(text): - """ - Function to decode a text. - @param text text to decode (string) - @return decoded text and encoding - """ - try: - if text.startswith(BOM_UTF8): - # UTF-8 with BOM - return to_text_string(text[len(BOM_UTF8):], 'utf-8'), 'utf-8-bom' - elif text.startswith(BOM_UTF16): - # UTF-16 with BOM - return to_text_string(text[len(BOM_UTF16):], 'utf-16'), 'utf-16' - elif text.startswith(BOM_UTF32): - # UTF-32 with BOM - return to_text_string(text[len(BOM_UTF32):], 'utf-32'), 'utf-32' - coding = get_coding(text) - if coding: - return to_text_string(text, coding), coding - except (UnicodeError, LookupError): - pass - # Assume UTF-8 - try: - return to_text_string(text, 'utf-8'), 'utf-8-guessed' - except (UnicodeError, LookupError): - pass - # Assume Latin-1 (behaviour before 3.7.1) - return to_text_string(text, "latin-1"), 'latin-1-guessed' - -def encode(text, orig_coding): - """ - Function to encode a text. - @param text text to encode (string) - @param orig_coding type of the original coding (string) - @return encoded text and encoding - """ - if orig_coding == 'utf-8-bom': - return BOM_UTF8 + text.encode("utf-8"), 'utf-8-bom' - - # Try saving with original encoding - if orig_coding: - try: - return text.encode(orig_coding), orig_coding - except (UnicodeError, LookupError): - pass - - # Try declared coding spec - coding = get_coding(text) - if coding: - try: - return text.encode(coding), coding - except (UnicodeError, LookupError): - raise RuntimeError("Incorrect encoding (%s)" % coding) - if orig_coding and orig_coding.endswith('-default') or \ - orig_coding.endswith('-guessed'): - coding = orig_coding.replace("-default", "") - coding = orig_coding.replace("-guessed", "") - try: - return text.encode(coding), coding - except (UnicodeError, LookupError): - pass - - # Save as UTF-8 without BOM - return text.encode('utf-8'), 'utf-8' - -def to_unicode(string): - """Convert a string to unicode""" - if not is_unicode(string): - for codec in CODECS: - try: - unic = to_text_string(string, codec) - except UnicodeError: - pass - except TypeError: - break - else: - return unic - return string - - -def write(text, filename, encoding='utf-8', mode='wb'): - """ - Write 'text' to file ('filename') assuming 'encoding' in an atomic way - Return (eventually new) encoding - """ - text, encoding = encode(text, encoding) - - if os.name == 'nt': - try: - absolute_path_filename = pathlib.Path(filename).resolve() - if absolute_path_filename.exists(): - absolute_filename = to_text_string(absolute_path_filename) - else: - absolute_filename = osp.realpath(filename) - except (OSError, RuntimeError): - absolute_filename = osp.realpath(filename) - else: - absolute_filename = osp.realpath(filename) - - if 'a' in mode: - with open(absolute_filename, mode) as textfile: - textfile.write(text) - else: - # Based in the solution at untitaker/python-atomicwrites#42. - # Needed to fix file permissions overwriting. - # See spyder-ide/spyder#9381. - try: - file_stat = os.stat(absolute_filename) - original_mode = file_stat.st_mode - creation = file_stat.st_atime - except OSError: # Change to FileNotFoundError for PY3 - # Creating a new file, emulate what os.open() does - umask = os.umask(0) - os.umask(umask) - # Set base permission of a file to standard permissions. - # See #spyder-ide/spyder#14112. - original_mode = 0o666 & ~umask - creation = time.time() - try: - # fixes issues with scripts in Dropbox leaving - # temporary files in the folder, see spyder-ide/spyder#13041 - tempfolder = None - if 'dropbox' in absolute_filename.lower(): - tempfolder = tempfile.gettempdir() - with atomic_write(absolute_filename, overwrite=True, - mode=mode, dir=tempfolder) as textfile: - textfile.write(text) - except OSError as error: - # Some filesystems don't support the option to sync directories - # See untitaker/python-atomicwrites#17 - if error.errno != errno.EINVAL: - with open(absolute_filename, mode) as textfile: - textfile.write(text) - try: - os.chmod(absolute_filename, original_mode) - file_stat = os.stat(absolute_filename) - # Preserve creation timestamps - os.utime(absolute_filename, (creation, file_stat.st_mtime)) - except OSError: - # Prevent error when chmod/utime is not allowed - # See spyder-ide/spyder#11308 - pass - return encoding - - -def writelines(lines, filename, encoding='utf-8', mode='wb'): - """ - Write 'lines' to file ('filename') assuming 'encoding' - Return (eventually new) encoding - """ - return write(os.linesep.join(lines), filename, encoding, mode) - -def read(filename, encoding='utf-8'): - """ - Read text from file ('filename') - Return text and encoding - """ - text, encoding = decode( open(filename, 'rb').read() ) - return text, encoding - -def readlines(filename, encoding='utf-8'): - """ - Read lines from file ('filename') - Return lines and encoding - """ - text, encoding = read(filename, encoding) - return text.split(os.linesep), encoding - - -def is_text_file(filename): - """ - Test if the given path is a text-like file. - """ - try: - return not is_binary(filename) - except (OSError, IOError): - return False +# -*- coding: utf-8 -*- +# +# Copyright © Spyder Project Contributors +# Licensed under the terms of the MIT License +# (see spyder/__init__.py for details) + +""" +Text encoding utilities, text file I/O + +Functions 'get_coding', 'decode', 'encode' and 'to_unicode' come from Eric4 +source code (Utilities/__init___.py) Copyright © 2003-2009 Detlev Offenbach +""" + +# Standard library imports +from codecs import BOM_UTF8, BOM_UTF16, BOM_UTF32 +import tempfile +import locale +import re +import os +import os.path as osp +import sys +import time +import errno + +# Third-party imports +from chardet.universaldetector import UniversalDetector +from atomicwrites import atomic_write + +# Local imports +from spyder.py3compat import (is_string, to_text_string, is_binary_string, + is_unicode, PY2) +from spyder.utils.external.binaryornot.check import is_binary + +if PY2: + import pathlib2 as pathlib +else: + import pathlib + + +PREFERRED_ENCODING = locale.getpreferredencoding() + +def transcode(text, input=PREFERRED_ENCODING, output=PREFERRED_ENCODING): + """Transcode a text string""" + try: + return text.decode("cp437").encode("cp1252") + except UnicodeError: + try: + return text.decode("cp437").encode(output) + except UnicodeError: + return text + +#------------------------------------------------------------------------------ +# Functions for encoding and decoding bytes that come from +# the *file system*. +#------------------------------------------------------------------------------ + +# The default encoding for file paths and environment variables should be set +# to match the default encoding that the OS is using. +def getfilesystemencoding(): + """ + Query the filesystem for the encoding used to encode filenames + and environment variables. + """ + encoding = sys.getfilesystemencoding() + if encoding is None: + # Must be Linux or Unix and nl_langinfo(CODESET) failed. + encoding = PREFERRED_ENCODING + return encoding + +FS_ENCODING = getfilesystemencoding() + +def to_unicode_from_fs(string): + """ + Return a unicode version of string decoded using the file system encoding. + """ + if not is_string(string): # string is a QString + string = to_text_string(string.toUtf8(), 'utf-8') + else: + if is_binary_string(string): + try: + unic = string.decode(FS_ENCODING) + except (UnicodeError, TypeError): + pass + else: + return unic + return string + +def to_fs_from_unicode(unic): + """ + Return a byte string version of unic encoded using the file + system encoding. + """ + if is_unicode(unic): + try: + string = unic.encode(FS_ENCODING) + except (UnicodeError, TypeError): + pass + else: + return string + return unic + +#------------------------------------------------------------------------------ +# Functions for encoding and decoding *text data* itself, usually originating +# from or destined for the *contents* of a file. +#------------------------------------------------------------------------------ + +# Codecs for working with files and text. +CODING_RE = re.compile(r"coding[:=]\s*([-\w_.]+)") +CODECS = ['utf-8', 'iso8859-1', 'iso8859-15', 'ascii', 'koi8-r', 'cp1251', + 'koi8-u', 'iso8859-2', 'iso8859-3', 'iso8859-4', 'iso8859-5', + 'iso8859-6', 'iso8859-7', 'iso8859-8', 'iso8859-9', + 'iso8859-10', 'iso8859-13', 'iso8859-14', 'latin-1', + 'utf-16'] + + +def get_coding(text, force_chardet=False): + """ + Function to get the coding of a text. + @param text text to inspect (string) + @return coding string + """ + if not force_chardet: + for line in text.splitlines()[:2]: + try: + result = CODING_RE.search(to_text_string(line)) + except UnicodeDecodeError: + # This could fail because to_text_string assume the text + # is utf8-like and we don't know the encoding to give + # it to to_text_string + pass + else: + if result: + codec = result.group(1) + # sometimes we find a false encoding that can + # result in errors + if codec in CODECS: + return codec + + # Fallback using chardet + if is_binary_string(text): + detector = UniversalDetector() + for line in text.splitlines()[:2]: + detector.feed(line) + if detector.done: break + + detector.close() + return detector.result['encoding'] + + return None + +def decode(text): + """ + Function to decode a text. + @param text text to decode (string) + @return decoded text and encoding + """ + try: + if text.startswith(BOM_UTF8): + # UTF-8 with BOM + return to_text_string(text[len(BOM_UTF8):], 'utf-8'), 'utf-8-bom' + elif text.startswith(BOM_UTF16): + # UTF-16 with BOM + return to_text_string(text[len(BOM_UTF16):], 'utf-16'), 'utf-16' + elif text.startswith(BOM_UTF32): + # UTF-32 with BOM + return to_text_string(text[len(BOM_UTF32):], 'utf-32'), 'utf-32' + coding = get_coding(text) + if coding: + return to_text_string(text, coding), coding + except (UnicodeError, LookupError): + pass + # Assume UTF-8 + try: + return to_text_string(text, 'utf-8'), 'utf-8-guessed' + except (UnicodeError, LookupError): + pass + # Assume Latin-1 (behaviour before 3.7.1) + return to_text_string(text, "latin-1"), 'latin-1-guessed' + +def encode(text, orig_coding): + """ + Function to encode a text. + @param text text to encode (string) + @param orig_coding type of the original coding (string) + @return encoded text and encoding + """ + if orig_coding == 'utf-8-bom': + return BOM_UTF8 + text.encode("utf-8"), 'utf-8-bom' + + # Try saving with original encoding + if orig_coding: + try: + return text.encode(orig_coding), orig_coding + except (UnicodeError, LookupError): + pass + + # Try declared coding spec + coding = get_coding(text) + if coding: + try: + return text.encode(coding), coding + except (UnicodeError, LookupError): + raise RuntimeError("Incorrect encoding (%s)" % coding) + if orig_coding and orig_coding.endswith('-default') or \ + orig_coding.endswith('-guessed'): + coding = orig_coding.replace("-default", "") + coding = orig_coding.replace("-guessed", "") + try: + return text.encode(coding), coding + except (UnicodeError, LookupError): + pass + + # Save as UTF-8 without BOM + return text.encode('utf-8'), 'utf-8' + +def to_unicode(string): + """Convert a string to unicode""" + if not is_unicode(string): + for codec in CODECS: + try: + unic = to_text_string(string, codec) + except UnicodeError: + pass + except TypeError: + break + else: + return unic + return string + + +def write(text, filename, encoding='utf-8', mode='wb'): + """ + Write 'text' to file ('filename') assuming 'encoding' in an atomic way + Return (eventually new) encoding + """ + text, encoding = encode(text, encoding) + + if os.name == 'nt': + try: + absolute_path_filename = pathlib.Path(filename).resolve() + if absolute_path_filename.exists(): + absolute_filename = to_text_string(absolute_path_filename) + else: + absolute_filename = osp.realpath(filename) + except (OSError, RuntimeError): + absolute_filename = osp.realpath(filename) + else: + absolute_filename = osp.realpath(filename) + + if 'a' in mode: + with open(absolute_filename, mode) as textfile: + textfile.write(text) + else: + # Based in the solution at untitaker/python-atomicwrites#42. + # Needed to fix file permissions overwriting. + # See spyder-ide/spyder#9381. + try: + file_stat = os.stat(absolute_filename) + original_mode = file_stat.st_mode + creation = file_stat.st_atime + except OSError: # Change to FileNotFoundError for PY3 + # Creating a new file, emulate what os.open() does + umask = os.umask(0) + os.umask(umask) + # Set base permission of a file to standard permissions. + # See #spyder-ide/spyder#14112. + original_mode = 0o666 & ~umask + creation = time.time() + try: + # fixes issues with scripts in Dropbox leaving + # temporary files in the folder, see spyder-ide/spyder#13041 + tempfolder = None + if 'dropbox' in absolute_filename.lower(): + tempfolder = tempfile.gettempdir() + with atomic_write(absolute_filename, overwrite=True, + mode=mode, dir=tempfolder) as textfile: + textfile.write(text) + except OSError as error: + # Some filesystems don't support the option to sync directories + # See untitaker/python-atomicwrites#17 + if error.errno != errno.EINVAL: + with open(absolute_filename, mode) as textfile: + textfile.write(text) + try: + os.chmod(absolute_filename, original_mode) + file_stat = os.stat(absolute_filename) + # Preserve creation timestamps + os.utime(absolute_filename, (creation, file_stat.st_mtime)) + except OSError: + # Prevent error when chmod/utime is not allowed + # See spyder-ide/spyder#11308 + pass + return encoding + + +def writelines(lines, filename, encoding='utf-8', mode='wb'): + """ + Write 'lines' to file ('filename') assuming 'encoding' + Return (eventually new) encoding + """ + return write(os.linesep.join(lines), filename, encoding, mode) + +def read(filename, encoding='utf-8'): + """ + Read text from file ('filename') + Return text and encoding + """ + text, encoding = decode( open(filename, 'rb').read() ) + return text, encoding + +def readlines(filename, encoding='utf-8'): + """ + Read lines from file ('filename') + Return lines and encoding + """ + text, encoding = read(filename, encoding) + return text.split(os.linesep), encoding + + +def is_text_file(filename): + """ + Test if the given path is a text-like file. + """ + try: + return not is_binary(filename) + except (OSError, IOError): + return False diff --git a/spyder/utils/environ.py b/spyder/utils/environ.py index a011fc2c4c9..50c53b19f83 100644 --- a/spyder/utils/environ.py +++ b/spyder/utils/environ.py @@ -1,186 +1,186 @@ -# -*- coding: utf-8 -*- -# -# Copyright © Spyder Project Contributors -# Licensed under the terms of the MIT License -# (see spyder/__init__.py for details) - -""" -Environment variable utilities. -""" - -# Standard library imports -import os - -# Third party imports -from qtpy.QtWidgets import QDialog, QMessageBox - -# Local imports -from spyder.config.base import _ -from spyder.widgets.collectionseditor import CollectionsEditor -from spyder.py3compat import PY2, iteritems, to_text_string, to_binary_string -from spyder.utils.icon_manager import ima -from spyder.utils.encoding import to_unicode_from_fs - - -def envdict2listdict(envdict): - """Dict --> Dict of lists""" - sep = os.path.pathsep - for key in envdict: - if sep in envdict[key]: - envdict[key] = [path.strip() for path in envdict[key].split(sep)] - return envdict - - -def listdict2envdict(listdict): - """Dict of lists --> Dict""" - for key in listdict: - if isinstance(listdict[key], list): - listdict[key] = os.path.pathsep.join(listdict[key]) - return listdict - - -def clean_env(env_vars): - """ - Remove non-ascii entries from a dictionary of environments variables. - - The values will be converted to strings or bytes (on Python 2). If an - exception is raised, an empty string will be used. - """ - new_env_vars = env_vars.copy() - for key, var in iteritems(env_vars): - if PY2: - # Try to convert vars first to utf-8. - try: - unicode_var = to_text_string(var) - except UnicodeDecodeError: - # If that fails, try to use the file system - # encoding because one of our vars is our - # PYTHONPATH, and that contains file system - # directories - try: - unicode_var = to_unicode_from_fs(var) - except Exception: - # If that also fails, make the var empty - # to be able to start Spyder. - # See https://stackoverflow.com/q/44506900/438386 - # for details. - unicode_var = '' - new_env_vars[key] = to_binary_string(unicode_var, encoding='utf-8') - else: - new_env_vars[key] = to_text_string(var) - - return new_env_vars - - -class RemoteEnvDialog(CollectionsEditor): - """Remote process environment variables dialog.""" - - def __init__(self, environ, parent=None): - super(RemoteEnvDialog, self).__init__(parent) - try: - self.setup( - envdict2listdict(environ), - title=_("Environment variables"), - readonly=True, - icon=ima.icon('environ') - ) - except Exception as e: - QMessageBox.warning( - parent, - _("Warning"), - _("An error occurred while trying to show your " - "environment variables. The error was

    " - "{0}").format(e), - QMessageBox.Ok - ) - - -class EnvDialog(RemoteEnvDialog): - """Environment variables Dialog""" - def __init__(self, parent=None): - RemoteEnvDialog.__init__(self, dict(os.environ), parent=parent) - - -# For Windows only -try: - from spyder.py3compat import winreg - - def get_user_env(): - """Return HKCU (current user) environment variables""" - reg = dict() - key = winreg.OpenKey(winreg.HKEY_CURRENT_USER, "Environment") - for index in range(0, winreg.QueryInfoKey(key)[1]): - try: - value = winreg.EnumValue(key, index) - reg[value[0]] = value[1] - except: - break - return envdict2listdict(reg) - - def set_user_env(reg, parent=None): - """Set HKCU (current user) environment variables""" - reg = listdict2envdict(reg) - types = dict() - key = winreg.OpenKey(winreg.HKEY_CURRENT_USER, "Environment") - for name in reg: - try: - _x, types[name] = winreg.QueryValueEx(key, name) - except WindowsError: - types[name] = winreg.REG_EXPAND_SZ - key = winreg.OpenKey(winreg.HKEY_CURRENT_USER, "Environment", 0, - winreg.KEY_SET_VALUE) - for name in reg: - winreg.SetValueEx(key, name, 0, types[name], reg[name]) - try: - from win32gui import SendMessageTimeout - from win32con import (HWND_BROADCAST, WM_SETTINGCHANGE, - SMTO_ABORTIFHUNG) - SendMessageTimeout(HWND_BROADCAST, WM_SETTINGCHANGE, 0, - "Environment", SMTO_ABORTIFHUNG, 5000) - except Exception: - QMessageBox.warning(parent, _("Warning"), - _("Module pywin32 was not found.
    " - "Please restart this Windows session " - "(not the computer) for changes to take effect.")) - - class WinUserEnvDialog(CollectionsEditor): - """Windows User Environment Variables Editor""" - def __init__(self, parent=None): - super(WinUserEnvDialog, self).__init__(parent) - self.setup(get_user_env(), - title=r"HKEY_CURRENT_USER\Environment") - if parent is None: - parent = self - QMessageBox.warning(parent, _("Warning"), - _("If you accept changes, " - "this will modify the current user environment " - "variables directly in Windows registry. " - "Use it with precautions, at your own risks.
    " - "
    Note that for changes to take effect, you will " - "need to restart the parent process of this applica" - "tion (simply restart Spyder if you have executed it " - "from a Windows shortcut, otherwise restart any " - "application from which you may have executed it, " - "like Python(x,y) Home for example)")) - - def accept(self): - """Reimplement Qt method""" - set_user_env(listdict2envdict(self.get_value()), parent=self) - QDialog.accept(self) - -except Exception: - pass - -def main(): - """Run Windows environment variable editor""" - from spyder.utils.qthelpers import qapplication - app = qapplication() - if os.name == 'nt': - dialog = WinUserEnvDialog() - else: - dialog = EnvDialog() - dialog.show() - app.exec_() - -if __name__ == "__main__": - main() +# -*- coding: utf-8 -*- +# +# Copyright © Spyder Project Contributors +# Licensed under the terms of the MIT License +# (see spyder/__init__.py for details) + +""" +Environment variable utilities. +""" + +# Standard library imports +import os + +# Third party imports +from qtpy.QtWidgets import QDialog, QMessageBox + +# Local imports +from spyder.config.base import _ +from spyder.widgets.collectionseditor import CollectionsEditor +from spyder.py3compat import PY2, iteritems, to_text_string, to_binary_string +from spyder.utils.icon_manager import ima +from spyder.utils.encoding import to_unicode_from_fs + + +def envdict2listdict(envdict): + """Dict --> Dict of lists""" + sep = os.path.pathsep + for key in envdict: + if sep in envdict[key]: + envdict[key] = [path.strip() for path in envdict[key].split(sep)] + return envdict + + +def listdict2envdict(listdict): + """Dict of lists --> Dict""" + for key in listdict: + if isinstance(listdict[key], list): + listdict[key] = os.path.pathsep.join(listdict[key]) + return listdict + + +def clean_env(env_vars): + """ + Remove non-ascii entries from a dictionary of environments variables. + + The values will be converted to strings or bytes (on Python 2). If an + exception is raised, an empty string will be used. + """ + new_env_vars = env_vars.copy() + for key, var in iteritems(env_vars): + if PY2: + # Try to convert vars first to utf-8. + try: + unicode_var = to_text_string(var) + except UnicodeDecodeError: + # If that fails, try to use the file system + # encoding because one of our vars is our + # PYTHONPATH, and that contains file system + # directories + try: + unicode_var = to_unicode_from_fs(var) + except Exception: + # If that also fails, make the var empty + # to be able to start Spyder. + # See https://stackoverflow.com/q/44506900/438386 + # for details. + unicode_var = '' + new_env_vars[key] = to_binary_string(unicode_var, encoding='utf-8') + else: + new_env_vars[key] = to_text_string(var) + + return new_env_vars + + +class RemoteEnvDialog(CollectionsEditor): + """Remote process environment variables dialog.""" + + def __init__(self, environ, parent=None): + super(RemoteEnvDialog, self).__init__(parent) + try: + self.setup( + envdict2listdict(environ), + title=_("Environment variables"), + readonly=True, + icon=ima.icon('environ') + ) + except Exception as e: + QMessageBox.warning( + parent, + _("Warning"), + _("An error occurred while trying to show your " + "environment variables. The error was

    " + "{0}").format(e), + QMessageBox.Ok + ) + + +class EnvDialog(RemoteEnvDialog): + """Environment variables Dialog""" + def __init__(self, parent=None): + RemoteEnvDialog.__init__(self, dict(os.environ), parent=parent) + + +# For Windows only +try: + from spyder.py3compat import winreg + + def get_user_env(): + """Return HKCU (current user) environment variables""" + reg = dict() + key = winreg.OpenKey(winreg.HKEY_CURRENT_USER, "Environment") + for index in range(0, winreg.QueryInfoKey(key)[1]): + try: + value = winreg.EnumValue(key, index) + reg[value[0]] = value[1] + except: + break + return envdict2listdict(reg) + + def set_user_env(reg, parent=None): + """Set HKCU (current user) environment variables""" + reg = listdict2envdict(reg) + types = dict() + key = winreg.OpenKey(winreg.HKEY_CURRENT_USER, "Environment") + for name in reg: + try: + _x, types[name] = winreg.QueryValueEx(key, name) + except WindowsError: + types[name] = winreg.REG_EXPAND_SZ + key = winreg.OpenKey(winreg.HKEY_CURRENT_USER, "Environment", 0, + winreg.KEY_SET_VALUE) + for name in reg: + winreg.SetValueEx(key, name, 0, types[name], reg[name]) + try: + from win32gui import SendMessageTimeout + from win32con import (HWND_BROADCAST, WM_SETTINGCHANGE, + SMTO_ABORTIFHUNG) + SendMessageTimeout(HWND_BROADCAST, WM_SETTINGCHANGE, 0, + "Environment", SMTO_ABORTIFHUNG, 5000) + except Exception: + QMessageBox.warning(parent, _("Warning"), + _("Module pywin32 was not found.
    " + "Please restart this Windows session " + "(not the computer) for changes to take effect.")) + + class WinUserEnvDialog(CollectionsEditor): + """Windows User Environment Variables Editor""" + def __init__(self, parent=None): + super(WinUserEnvDialog, self).__init__(parent) + self.setup(get_user_env(), + title=r"HKEY_CURRENT_USER\Environment") + if parent is None: + parent = self + QMessageBox.warning(parent, _("Warning"), + _("If you accept changes, " + "this will modify the current user environment " + "variables directly in Windows registry. " + "Use it with precautions, at your own risks.
    " + "
    Note that for changes to take effect, you will " + "need to restart the parent process of this applica" + "tion (simply restart Spyder if you have executed it " + "from a Windows shortcut, otherwise restart any " + "application from which you may have executed it, " + "like Python(x,y) Home for example)")) + + def accept(self): + """Reimplement Qt method""" + set_user_env(listdict2envdict(self.get_value()), parent=self) + QDialog.accept(self) + +except Exception: + pass + +def main(): + """Run Windows environment variable editor""" + from spyder.utils.qthelpers import qapplication + app = qapplication() + if os.name == 'nt': + dialog = WinUserEnvDialog() + else: + dialog = EnvDialog() + dialog.show() + app.exec_() + +if __name__ == "__main__": + main() diff --git a/spyder/utils/external/lockfile.py b/spyder/utils/external/lockfile.py index 0b43cbfddf5..41bf0f7b095 100644 --- a/spyder/utils/external/lockfile.py +++ b/spyder/utils/external/lockfile.py @@ -1,251 +1,251 @@ -# -*- coding: utf-8 -*- -# ----------------------------------------------------------------------------- -# Copyright (c) 2005 Divmod, Inc. -# Copyright (c) 2008-2011 Twisted Matrix Laboratories -# Copyright (c) 2012- Spyder Project Contributors -# -# Distributed under the terms of the MIT (Expat) License -# (see LICENSE.txt in this directory and NOTICE.txt in the root for details) -# ----------------------------------------------------------------------------- - -""" -Filesystem-based interprocess mutex. - -Taken from the Twisted project. -Distributed under the MIT (Expat) license. - -Changes by the Spyder Team to the original module: - * Rewrite kill Windows function to make it more reliable. - * Detect if the process that owns the lock is an Spyder one. - -Adapted from src/twisted/python/lockfile.py of the -`Twisted project `_. -""" - -__metaclass__ = type - -import errno, os -from time import time as _uniquefloat - -from spyder.py3compat import PY2, to_binary_string -from spyder.utils.programs import is_spyder_process - -def unique(): - if PY2: - return str(long(_uniquefloat() * 1000)) - else: - return str(int(_uniquefloat() * 1000)) - -from os import rename -if not os.name == 'nt': - from os import kill - from os import symlink - from os import readlink - from os import remove as rmlink - _windows = False -else: - _windows = True - - import ctypes - from ctypes import wintypes - - # https://docs.microsoft.com/en-us/windows/desktop/ProcThread/process-security-and-access-rights - PROCESS_QUERY_INFORMATION = 0x400 - - # GetExitCodeProcess uses a special exit code to indicate that the - # process is still running. - STILL_ACTIVE = 259 - - def _is_pid_running(pid): - """Taken from https://www.madebuild.org/blog/?p=30""" - kernel32 = ctypes.windll.kernel32 - handle = kernel32.OpenProcess(PROCESS_QUERY_INFORMATION, 0, pid) - if handle == 0: - return False - - # If the process exited recently, a pid may still exist for the - # handle. So, check if we can get the exit code. - exit_code = wintypes.DWORD() - retval = kernel32.GetExitCodeProcess(handle, - ctypes.byref(exit_code)) - is_running = (retval == 0) - kernel32.CloseHandle(handle) - - # See if we couldn't get the exit code or the exit code indicates - # that the process is still running. - return is_running or exit_code.value == STILL_ACTIVE - - def kill(pid, signal): # analysis:ignore - if not _is_pid_running(pid): - raise OSError(errno.ESRCH, None) - else: - return - - _open = open - - # XXX Implement an atomic thingamajig for win32 - def symlink(value, filename): #analysis:ignore - newlinkname = filename+"."+unique()+'.newlink' - newvalname = os.path.join(newlinkname, "symlink") - os.mkdir(newlinkname) - f = _open(newvalname, 'wb') - f.write(to_binary_string(value)) - f.flush() - f.close() - try: - rename(newlinkname, filename) - except: - # This is needed to avoid an error when we don't - # have permissions to write in ~/.spyder - # See issues 6319 and 9093 - try: - os.remove(newvalname) - os.rmdir(newlinkname) - except (IOError, OSError): - return - raise - - def readlink(filename): #analysis:ignore - try: - fObj = _open(os.path.join(filename, 'symlink'), 'rb') - except IOError as e: - if e.errno == errno.ENOENT or e.errno == errno.EIO: - raise OSError(e.errno, None) - raise - else: - result = fObj.read().decode() - fObj.close() - return result - - def rmlink(filename): #analysis:ignore - os.remove(os.path.join(filename, 'symlink')) - os.rmdir(filename) - - - -class FilesystemLock: - """ - A mutex. - - This relies on the filesystem property that creating - a symlink is an atomic operation and that it will - fail if the symlink already exists. Deleting the - symlink will release the lock. - - @ivar name: The name of the file associated with this lock. - - @ivar clean: Indicates whether this lock was released cleanly by its - last owner. Only meaningful after C{lock} has been called and - returns True. - - @ivar locked: Indicates whether the lock is currently held by this - object. - """ - - clean = None - locked = False - - def __init__(self, name): - self.name = name - - def lock(self): - """ - Acquire this lock. - - @rtype: C{bool} - @return: True if the lock is acquired, false otherwise. - - @raise: Any exception os.symlink() may raise, other than - EEXIST. - """ - clean = True - while True: - try: - symlink(str(os.getpid()), self.name) - except OSError as e: - if _windows and e.errno in (errno.EACCES, errno.EIO): - # The lock is in the middle of being deleted because we're - # on Windows where lock removal isn't atomic. Give up, we - # don't know how long this is going to take. - return False - if e.errno == errno.EEXIST: - try: - pid = readlink(self.name) - except OSError as e: - if e.errno == errno.ENOENT: - # The lock has vanished, try to claim it in the - # next iteration through the loop. - continue - raise - except IOError as e: - if _windows and e.errno == errno.EACCES: - # The lock is in the middle of being - # deleted because we're on Windows where - # lock removal isn't atomic. Give up, we - # don't know how long this is going to - # take. - return False - raise - try: - if kill is not None: - kill(int(pid), 0) - if not is_spyder_process(int(pid)): - raise(OSError(errno.ESRCH, 'No such process')) - except OSError as e: - if e.errno == errno.ESRCH: - # The owner has vanished, try to claim it in the - # next iteration through the loop. - try: - rmlink(self.name) - except OSError as e: - if e.errno == errno.ENOENT: - # Another process cleaned up the lock. - # Race them to acquire it in the next - # iteration through the loop. - continue - raise - clean = False - continue - raise - return False - raise - self.locked = True - self.clean = clean - return True - - def unlock(self): - """ - Release this lock. - - This deletes the directory with the given name. - - @raise: Any exception os.readlink() may raise, or - ValueError if the lock is not owned by this process. - """ - pid = readlink(self.name) - if int(pid) != os.getpid(): - raise ValueError("Lock %r not owned by this process" % (self.name,)) - rmlink(self.name) - self.locked = False - - -def isLocked(name): - """Determine if the lock of the given name is held or not. - - @type name: C{str} - @param name: The filesystem path to the lock to test - - @rtype: C{bool} - @return: True if the lock is held, False otherwise. - """ - l = FilesystemLock(name) - result = None - try: - result = l.lock() - finally: - if result: - l.unlock() - return not result - - -__all__ = ['FilesystemLock', 'isLocked'] +# -*- coding: utf-8 -*- +# ----------------------------------------------------------------------------- +# Copyright (c) 2005 Divmod, Inc. +# Copyright (c) 2008-2011 Twisted Matrix Laboratories +# Copyright (c) 2012- Spyder Project Contributors +# +# Distributed under the terms of the MIT (Expat) License +# (see LICENSE.txt in this directory and NOTICE.txt in the root for details) +# ----------------------------------------------------------------------------- + +""" +Filesystem-based interprocess mutex. + +Taken from the Twisted project. +Distributed under the MIT (Expat) license. + +Changes by the Spyder Team to the original module: + * Rewrite kill Windows function to make it more reliable. + * Detect if the process that owns the lock is an Spyder one. + +Adapted from src/twisted/python/lockfile.py of the +`Twisted project `_. +""" + +__metaclass__ = type + +import errno, os +from time import time as _uniquefloat + +from spyder.py3compat import PY2, to_binary_string +from spyder.utils.programs import is_spyder_process + +def unique(): + if PY2: + return str(long(_uniquefloat() * 1000)) + else: + return str(int(_uniquefloat() * 1000)) + +from os import rename +if not os.name == 'nt': + from os import kill + from os import symlink + from os import readlink + from os import remove as rmlink + _windows = False +else: + _windows = True + + import ctypes + from ctypes import wintypes + + # https://docs.microsoft.com/en-us/windows/desktop/ProcThread/process-security-and-access-rights + PROCESS_QUERY_INFORMATION = 0x400 + + # GetExitCodeProcess uses a special exit code to indicate that the + # process is still running. + STILL_ACTIVE = 259 + + def _is_pid_running(pid): + """Taken from https://www.madebuild.org/blog/?p=30""" + kernel32 = ctypes.windll.kernel32 + handle = kernel32.OpenProcess(PROCESS_QUERY_INFORMATION, 0, pid) + if handle == 0: + return False + + # If the process exited recently, a pid may still exist for the + # handle. So, check if we can get the exit code. + exit_code = wintypes.DWORD() + retval = kernel32.GetExitCodeProcess(handle, + ctypes.byref(exit_code)) + is_running = (retval == 0) + kernel32.CloseHandle(handle) + + # See if we couldn't get the exit code or the exit code indicates + # that the process is still running. + return is_running or exit_code.value == STILL_ACTIVE + + def kill(pid, signal): # analysis:ignore + if not _is_pid_running(pid): + raise OSError(errno.ESRCH, None) + else: + return + + _open = open + + # XXX Implement an atomic thingamajig for win32 + def symlink(value, filename): #analysis:ignore + newlinkname = filename+"."+unique()+'.newlink' + newvalname = os.path.join(newlinkname, "symlink") + os.mkdir(newlinkname) + f = _open(newvalname, 'wb') + f.write(to_binary_string(value)) + f.flush() + f.close() + try: + rename(newlinkname, filename) + except: + # This is needed to avoid an error when we don't + # have permissions to write in ~/.spyder + # See issues 6319 and 9093 + try: + os.remove(newvalname) + os.rmdir(newlinkname) + except (IOError, OSError): + return + raise + + def readlink(filename): #analysis:ignore + try: + fObj = _open(os.path.join(filename, 'symlink'), 'rb') + except IOError as e: + if e.errno == errno.ENOENT or e.errno == errno.EIO: + raise OSError(e.errno, None) + raise + else: + result = fObj.read().decode() + fObj.close() + return result + + def rmlink(filename): #analysis:ignore + os.remove(os.path.join(filename, 'symlink')) + os.rmdir(filename) + + + +class FilesystemLock: + """ + A mutex. + + This relies on the filesystem property that creating + a symlink is an atomic operation and that it will + fail if the symlink already exists. Deleting the + symlink will release the lock. + + @ivar name: The name of the file associated with this lock. + + @ivar clean: Indicates whether this lock was released cleanly by its + last owner. Only meaningful after C{lock} has been called and + returns True. + + @ivar locked: Indicates whether the lock is currently held by this + object. + """ + + clean = None + locked = False + + def __init__(self, name): + self.name = name + + def lock(self): + """ + Acquire this lock. + + @rtype: C{bool} + @return: True if the lock is acquired, false otherwise. + + @raise: Any exception os.symlink() may raise, other than + EEXIST. + """ + clean = True + while True: + try: + symlink(str(os.getpid()), self.name) + except OSError as e: + if _windows and e.errno in (errno.EACCES, errno.EIO): + # The lock is in the middle of being deleted because we're + # on Windows where lock removal isn't atomic. Give up, we + # don't know how long this is going to take. + return False + if e.errno == errno.EEXIST: + try: + pid = readlink(self.name) + except OSError as e: + if e.errno == errno.ENOENT: + # The lock has vanished, try to claim it in the + # next iteration through the loop. + continue + raise + except IOError as e: + if _windows and e.errno == errno.EACCES: + # The lock is in the middle of being + # deleted because we're on Windows where + # lock removal isn't atomic. Give up, we + # don't know how long this is going to + # take. + return False + raise + try: + if kill is not None: + kill(int(pid), 0) + if not is_spyder_process(int(pid)): + raise(OSError(errno.ESRCH, 'No such process')) + except OSError as e: + if e.errno == errno.ESRCH: + # The owner has vanished, try to claim it in the + # next iteration through the loop. + try: + rmlink(self.name) + except OSError as e: + if e.errno == errno.ENOENT: + # Another process cleaned up the lock. + # Race them to acquire it in the next + # iteration through the loop. + continue + raise + clean = False + continue + raise + return False + raise + self.locked = True + self.clean = clean + return True + + def unlock(self): + """ + Release this lock. + + This deletes the directory with the given name. + + @raise: Any exception os.readlink() may raise, or + ValueError if the lock is not owned by this process. + """ + pid = readlink(self.name) + if int(pid) != os.getpid(): + raise ValueError("Lock %r not owned by this process" % (self.name,)) + rmlink(self.name) + self.locked = False + + +def isLocked(name): + """Determine if the lock of the given name is held or not. + + @type name: C{str} + @param name: The filesystem path to the lock to test + + @rtype: C{bool} + @return: True if the lock is held, False otherwise. + """ + l = FilesystemLock(name) + result = None + try: + result = l.lock() + finally: + if result: + l.unlock() + return not result + + +__all__ = ['FilesystemLock', 'isLocked'] diff --git a/spyder/utils/introspection/module_completion.py b/spyder/utils/introspection/module_completion.py index 7cf0545b3db..785617594e8 100644 --- a/spyder/utils/introspection/module_completion.py +++ b/spyder/utils/introspection/module_completion.py @@ -1,75 +1,75 @@ -# -*- coding: utf-8 -*- -# ----------------------------------------------------------------------------- -# Copyright (c) 2010-2011 The IPython Development Team -# Copyright (c) 2011- Spyder Project Contributors -# -# Distributed under the terms of the Modified BSD License -# (BSD 3-clause; see NOTICE.txt in the Spyder root directory for details). -# ----------------------------------------------------------------------------- - -""" -Module completion auxiliary functions. -""" - -import pkgutil - -from pickleshare import PickleShareDB - -from spyder.config.base import get_conf_path - - -# List of preferred modules -PREFERRED_MODULES = ['numpy', 'scipy', 'sympy', 'pandas', 'networkx', - 'statsmodels', 'matplotlib', 'sklearn', 'skimage', - 'mpmath', 'os', 'pillow', 'OpenGL', 'array', 'audioop', - 'binascii', 'cPickle', 'cStringIO', 'cmath', - 'collections', 'datetime', 'errno', 'exceptions', 'gc', - 'importlib', 'itertools', 'math', 'mmap', - 'msvcrt', 'nt', 'operator', 'ast', 'signal', - 'sys', 'threading', 'time', 'wx', 'zipimport', - 'zlib', 'pytest', 'PyQt4', 'PyQt5', 'PySide', - 'PySide2', 'os.path'] - - -def get_submodules(mod): - """Get all submodules of a given module""" - def catch_exceptions(module): - pass - try: - m = __import__(mod) - submodules = [mod] - submods = pkgutil.walk_packages(m.__path__, m.__name__ + '.', - catch_exceptions) - for sm in submods: - sm_name = sm[1] - submodules.append(sm_name) - except ImportError: - return [] - except: - return [mod] - - return submodules - - -def get_preferred_submodules(): - """ - Get all submodules of the main scientific modules and others of our - interest - """ - # Path to the modules database - modules_path = get_conf_path('db') - - # Modules database - modules_db = PickleShareDB(modules_path) - - if 'submodules' in modules_db: - return modules_db['submodules'] - - submodules = [] - - for m in PREFERRED_MODULES: - submods = get_submodules(m) - submodules += submods - - modules_db['submodules'] = submodules - return submodules +# -*- coding: utf-8 -*- +# ----------------------------------------------------------------------------- +# Copyright (c) 2010-2011 The IPython Development Team +# Copyright (c) 2011- Spyder Project Contributors +# +# Distributed under the terms of the Modified BSD License +# (BSD 3-clause; see NOTICE.txt in the Spyder root directory for details). +# ----------------------------------------------------------------------------- + +""" +Module completion auxiliary functions. +""" + +import pkgutil + +from pickleshare import PickleShareDB + +from spyder.config.base import get_conf_path + + +# List of preferred modules +PREFERRED_MODULES = ['numpy', 'scipy', 'sympy', 'pandas', 'networkx', + 'statsmodels', 'matplotlib', 'sklearn', 'skimage', + 'mpmath', 'os', 'pillow', 'OpenGL', 'array', 'audioop', + 'binascii', 'cPickle', 'cStringIO', 'cmath', + 'collections', 'datetime', 'errno', 'exceptions', 'gc', + 'importlib', 'itertools', 'math', 'mmap', + 'msvcrt', 'nt', 'operator', 'ast', 'signal', + 'sys', 'threading', 'time', 'wx', 'zipimport', + 'zlib', 'pytest', 'PyQt4', 'PyQt5', 'PySide', + 'PySide2', 'os.path'] + + +def get_submodules(mod): + """Get all submodules of a given module""" + def catch_exceptions(module): + pass + try: + m = __import__(mod) + submodules = [mod] + submods = pkgutil.walk_packages(m.__path__, m.__name__ + '.', + catch_exceptions) + for sm in submods: + sm_name = sm[1] + submodules.append(sm_name) + except ImportError: + return [] + except: + return [mod] + + return submodules + + +def get_preferred_submodules(): + """ + Get all submodules of the main scientific modules and others of our + interest + """ + # Path to the modules database + modules_path = get_conf_path('db') + + # Modules database + modules_db = PickleShareDB(modules_path) + + if 'submodules' in modules_db: + return modules_db['submodules'] + + submodules = [] + + for m in PREFERRED_MODULES: + submods = get_submodules(m) + submodules += submods + + modules_db['submodules'] = submodules + return submodules diff --git a/spyder/utils/introspection/rope_patch.py b/spyder/utils/introspection/rope_patch.py index 7301030ffa9..bac6656ee42 100644 --- a/spyder/utils/introspection/rope_patch.py +++ b/spyder/utils/introspection/rope_patch.py @@ -1,211 +1,211 @@ -# -*- coding: utf-8 -*- -# -# Copyright © Spyder Project Contributors -# Licensed under the terms of the MIT License -# (see spyder/__init__.py for details) - -""" -Patching rope: - -[1] For compatibility with Spyder's standalone version, built with py2exe or - cx_Freeze - -[2] For better performance, see this thread: - https://groups.google.com/forum/#!topic/rope-dev/V95XMfICU3o - -[3] To avoid considering folders without __init__.py as Python packages, thus - avoiding side effects as non-working introspection features on a Python - module or package when a folder in current directory has the same name. - See this thread: - https://groups.google.com/forum/#!topic/rope-dev/kkxLWmJo5hg - -[4] To avoid rope adding a 2 spaces indent to every docstring it gets, because - it breaks the work of Sphinx on the Help plugin. Also, to better - control how to get calltips and docstrings of forced builtin objects. - -[5] To make matplotlib return its docstrings in proper rst, instead of a mix - of rst and plain text. -""" - -def apply(): - """Monkey patching rope - - See [1], [2], [3], [4] and [5] in module docstring.""" - from spyder.utils.programs import is_module_installed - if is_module_installed('rope', '<0.9.4'): - import rope - raise ImportError("rope %s can't be patched" % rope.VERSION) - - # [1] Patching project.Project for compatibility with py2exe/cx_Freeze - # distributions - from spyder.config.base import is_py2exe_or_cx_Freeze - if is_py2exe_or_cx_Freeze(): - from rope.base import project - class PatchedProject(project.Project): - def _default_config(self): - # py2exe/cx_Freeze distribution - from spyder.config.base import get_module_source_path - fname = get_module_source_path('spyder', - 'default_config.py') - return open(fname, 'rb').read() - project.Project = PatchedProject - - # Patching pycore.PyCore... - from rope.base import pycore - class PatchedPyCore(pycore.PyCore): - # [2] ...so that forced builtin modules (i.e. modules that were - # declared as 'extension_modules' in rope preferences) will be indeed - # recognized as builtins by rope, as expected - # - # This patch is included in rope 0.9.4+ but applying it anyway is ok - def get_module(self, name, folder=None): - """Returns a `PyObject` if the module was found.""" - # check if this is a builtin module - pymod = self._builtin_module(name) - if pymod is not None: - return pymod - module = self.find_module(name, folder) - if module is None: - raise pycore.ModuleNotFoundError( - 'Module %s not found' % name) - return self.resource_to_pyobject(module) - # [3] ...to avoid considering folders without __init__.py as Python - # packages - def _find_module_in_folder(self, folder, modname): - module = folder - packages = modname.split('.') - for pkg in packages[:-1]: - if module.is_folder() and module.has_child(pkg): - module = module.get_child(pkg) - else: - return None - if module.is_folder(): - if module.has_child(packages[-1]) and \ - module.get_child(packages[-1]).is_folder() and \ - module.get_child(packages[-1]).has_child('__init__.py'): - return module.get_child(packages[-1]) - elif module.has_child(packages[-1] + '.py') and \ - not module.get_child(packages[-1] + '.py').is_folder(): - return module.get_child(packages[-1] + '.py') - pycore.PyCore = PatchedPyCore - - # [2] Patching BuiltinName for the go to definition feature to simply work - # with forced builtins - from rope.base import builtins, libutils, pyobjects - import inspect - import os.path as osp - class PatchedBuiltinName(builtins.BuiltinName): - def _pycore(self): - p = self.pyobject - while p.parent is not None: - p = p.parent - if isinstance(p, builtins.BuiltinModule) and p.pycore is not None: - return p.pycore - def get_definition_location(self): - if not inspect.isbuiltin(self.pyobject): - _lines, lineno = inspect.getsourcelines(self.pyobject.builtin) - path = inspect.getfile(self.pyobject.builtin) - if path.endswith('pyc') and osp.isfile(path[:-1]): - path = path[:-1] - pycore = self._pycore() - if pycore and pycore.project: - resource = libutils.path_to_resource(pycore.project, path) - module = pyobjects.PyModule(pycore, None, resource) - return (module, lineno) - return (None, None) - builtins.BuiltinName = PatchedBuiltinName - - # [4] Patching several PyDocExtractor methods: - # 1. get_doc: - # To force rope to return the docstring of any object which has one, even - # if it's not an instance of AbstractFunction, AbstractClass, or - # AbstractModule. - # Also, to use utils.dochelpers.getdoc to get docs from forced builtins. - # - # 2. _get_class_docstring and _get_single_function_docstring: - # To not let rope add a 2 spaces indentation to every docstring, which was - # breaking our rich text mode. The only value that we are modifying is the - # 'indents' keyword of those methods, from 2 to 0. - # - # 3. get_calltip - # To easily get calltips of forced builtins - from rope.contrib import codeassist - from spyder_kernels.utils.dochelpers import getdoc - from rope.base import exceptions - class PatchedPyDocExtractor(codeassist.PyDocExtractor): - def get_builtin_doc(self, pyobject): - buitin = pyobject.builtin - return getdoc(buitin) - - def get_doc(self, pyobject): - if hasattr(pyobject, 'builtin'): - doc = self.get_builtin_doc(pyobject) - return doc - elif isinstance(pyobject, builtins.BuiltinModule): - docstring = pyobject.get_doc() - if docstring is not None: - docstring = self._trim_docstring(docstring) - else: - docstring = '' - # TODO: Add a module_name key, so that the name could appear - # on the OI text filed but not be used by sphinx to render - # the page - doc = {'name': '', - 'argspec': '', - 'note': '', - 'docstring': docstring - } - return doc - elif isinstance(pyobject, pyobjects.AbstractFunction): - return self._get_function_docstring(pyobject) - elif isinstance(pyobject, pyobjects.AbstractClass): - return self._get_class_docstring(pyobject) - elif isinstance(pyobject, pyobjects.AbstractModule): - return self._trim_docstring(pyobject.get_doc()) - elif pyobject.get_doc() is not None: # Spyder patch - return self._trim_docstring(pyobject.get_doc()) - return None - - def get_calltip(self, pyobject, ignore_unknown=False, remove_self=False): - if hasattr(pyobject, 'builtin'): - doc = self.get_builtin_doc(pyobject) - return doc['name'] + doc['argspec'] - try: - if isinstance(pyobject, pyobjects.AbstractClass): - pyobject = pyobject['__init__'].get_object() - if not isinstance(pyobject, pyobjects.AbstractFunction): - pyobject = pyobject['__call__'].get_object() - except exceptions.AttributeNotFoundError: - return None - if ignore_unknown and not isinstance(pyobject, pyobjects.PyFunction): - return - if isinstance(pyobject, pyobjects.AbstractFunction): - result = self._get_function_signature(pyobject, add_module=True) - if remove_self and self._is_method(pyobject): - return result.replace('(self)', '()').replace('(self, ', '(') - return result - - def _get_class_docstring(self, pyclass): - contents = self._trim_docstring(pyclass.get_doc(), indents=0) - supers = [super.get_name() for super in pyclass.get_superclasses()] - doc = 'class %s(%s):\n\n' % (pyclass.get_name(), ', '.join(supers)) + contents - - if '__init__' in pyclass: - init = pyclass['__init__'].get_object() - if isinstance(init, pyobjects.AbstractFunction): - doc += '\n\n' + self._get_single_function_docstring(init) - return doc - - def _get_single_function_docstring(self, pyfunction): - docs = pyfunction.get_doc() - docs = self._trim_docstring(docs, indents=0) - return docs - codeassist.PyDocExtractor = PatchedPyDocExtractor - - - # [5] Get the right matplotlib docstrings for Help - try: - import matplotlib as mpl - mpl.rcParams['docstring.hardcopy'] = True - except: - pass +# -*- coding: utf-8 -*- +# +# Copyright © Spyder Project Contributors +# Licensed under the terms of the MIT License +# (see spyder/__init__.py for details) + +""" +Patching rope: + +[1] For compatibility with Spyder's standalone version, built with py2exe or + cx_Freeze + +[2] For better performance, see this thread: + https://groups.google.com/forum/#!topic/rope-dev/V95XMfICU3o + +[3] To avoid considering folders without __init__.py as Python packages, thus + avoiding side effects as non-working introspection features on a Python + module or package when a folder in current directory has the same name. + See this thread: + https://groups.google.com/forum/#!topic/rope-dev/kkxLWmJo5hg + +[4] To avoid rope adding a 2 spaces indent to every docstring it gets, because + it breaks the work of Sphinx on the Help plugin. Also, to better + control how to get calltips and docstrings of forced builtin objects. + +[5] To make matplotlib return its docstrings in proper rst, instead of a mix + of rst and plain text. +""" + +def apply(): + """Monkey patching rope + + See [1], [2], [3], [4] and [5] in module docstring.""" + from spyder.utils.programs import is_module_installed + if is_module_installed('rope', '<0.9.4'): + import rope + raise ImportError("rope %s can't be patched" % rope.VERSION) + + # [1] Patching project.Project for compatibility with py2exe/cx_Freeze + # distributions + from spyder.config.base import is_py2exe_or_cx_Freeze + if is_py2exe_or_cx_Freeze(): + from rope.base import project + class PatchedProject(project.Project): + def _default_config(self): + # py2exe/cx_Freeze distribution + from spyder.config.base import get_module_source_path + fname = get_module_source_path('spyder', + 'default_config.py') + return open(fname, 'rb').read() + project.Project = PatchedProject + + # Patching pycore.PyCore... + from rope.base import pycore + class PatchedPyCore(pycore.PyCore): + # [2] ...so that forced builtin modules (i.e. modules that were + # declared as 'extension_modules' in rope preferences) will be indeed + # recognized as builtins by rope, as expected + # + # This patch is included in rope 0.9.4+ but applying it anyway is ok + def get_module(self, name, folder=None): + """Returns a `PyObject` if the module was found.""" + # check if this is a builtin module + pymod = self._builtin_module(name) + if pymod is not None: + return pymod + module = self.find_module(name, folder) + if module is None: + raise pycore.ModuleNotFoundError( + 'Module %s not found' % name) + return self.resource_to_pyobject(module) + # [3] ...to avoid considering folders without __init__.py as Python + # packages + def _find_module_in_folder(self, folder, modname): + module = folder + packages = modname.split('.') + for pkg in packages[:-1]: + if module.is_folder() and module.has_child(pkg): + module = module.get_child(pkg) + else: + return None + if module.is_folder(): + if module.has_child(packages[-1]) and \ + module.get_child(packages[-1]).is_folder() and \ + module.get_child(packages[-1]).has_child('__init__.py'): + return module.get_child(packages[-1]) + elif module.has_child(packages[-1] + '.py') and \ + not module.get_child(packages[-1] + '.py').is_folder(): + return module.get_child(packages[-1] + '.py') + pycore.PyCore = PatchedPyCore + + # [2] Patching BuiltinName for the go to definition feature to simply work + # with forced builtins + from rope.base import builtins, libutils, pyobjects + import inspect + import os.path as osp + class PatchedBuiltinName(builtins.BuiltinName): + def _pycore(self): + p = self.pyobject + while p.parent is not None: + p = p.parent + if isinstance(p, builtins.BuiltinModule) and p.pycore is not None: + return p.pycore + def get_definition_location(self): + if not inspect.isbuiltin(self.pyobject): + _lines, lineno = inspect.getsourcelines(self.pyobject.builtin) + path = inspect.getfile(self.pyobject.builtin) + if path.endswith('pyc') and osp.isfile(path[:-1]): + path = path[:-1] + pycore = self._pycore() + if pycore and pycore.project: + resource = libutils.path_to_resource(pycore.project, path) + module = pyobjects.PyModule(pycore, None, resource) + return (module, lineno) + return (None, None) + builtins.BuiltinName = PatchedBuiltinName + + # [4] Patching several PyDocExtractor methods: + # 1. get_doc: + # To force rope to return the docstring of any object which has one, even + # if it's not an instance of AbstractFunction, AbstractClass, or + # AbstractModule. + # Also, to use utils.dochelpers.getdoc to get docs from forced builtins. + # + # 2. _get_class_docstring and _get_single_function_docstring: + # To not let rope add a 2 spaces indentation to every docstring, which was + # breaking our rich text mode. The only value that we are modifying is the + # 'indents' keyword of those methods, from 2 to 0. + # + # 3. get_calltip + # To easily get calltips of forced builtins + from rope.contrib import codeassist + from spyder_kernels.utils.dochelpers import getdoc + from rope.base import exceptions + class PatchedPyDocExtractor(codeassist.PyDocExtractor): + def get_builtin_doc(self, pyobject): + buitin = pyobject.builtin + return getdoc(buitin) + + def get_doc(self, pyobject): + if hasattr(pyobject, 'builtin'): + doc = self.get_builtin_doc(pyobject) + return doc + elif isinstance(pyobject, builtins.BuiltinModule): + docstring = pyobject.get_doc() + if docstring is not None: + docstring = self._trim_docstring(docstring) + else: + docstring = '' + # TODO: Add a module_name key, so that the name could appear + # on the OI text filed but not be used by sphinx to render + # the page + doc = {'name': '', + 'argspec': '', + 'note': '', + 'docstring': docstring + } + return doc + elif isinstance(pyobject, pyobjects.AbstractFunction): + return self._get_function_docstring(pyobject) + elif isinstance(pyobject, pyobjects.AbstractClass): + return self._get_class_docstring(pyobject) + elif isinstance(pyobject, pyobjects.AbstractModule): + return self._trim_docstring(pyobject.get_doc()) + elif pyobject.get_doc() is not None: # Spyder patch + return self._trim_docstring(pyobject.get_doc()) + return None + + def get_calltip(self, pyobject, ignore_unknown=False, remove_self=False): + if hasattr(pyobject, 'builtin'): + doc = self.get_builtin_doc(pyobject) + return doc['name'] + doc['argspec'] + try: + if isinstance(pyobject, pyobjects.AbstractClass): + pyobject = pyobject['__init__'].get_object() + if not isinstance(pyobject, pyobjects.AbstractFunction): + pyobject = pyobject['__call__'].get_object() + except exceptions.AttributeNotFoundError: + return None + if ignore_unknown and not isinstance(pyobject, pyobjects.PyFunction): + return + if isinstance(pyobject, pyobjects.AbstractFunction): + result = self._get_function_signature(pyobject, add_module=True) + if remove_self and self._is_method(pyobject): + return result.replace('(self)', '()').replace('(self, ', '(') + return result + + def _get_class_docstring(self, pyclass): + contents = self._trim_docstring(pyclass.get_doc(), indents=0) + supers = [super.get_name() for super in pyclass.get_superclasses()] + doc = 'class %s(%s):\n\n' % (pyclass.get_name(), ', '.join(supers)) + contents + + if '__init__' in pyclass: + init = pyclass['__init__'].get_object() + if isinstance(init, pyobjects.AbstractFunction): + doc += '\n\n' + self._get_single_function_docstring(init) + return doc + + def _get_single_function_docstring(self, pyfunction): + docs = pyfunction.get_doc() + docs = self._trim_docstring(docs, indents=0) + return docs + codeassist.PyDocExtractor = PatchedPyDocExtractor + + + # [5] Get the right matplotlib docstrings for Help + try: + import matplotlib as mpl + mpl.rcParams['docstring.hardcopy'] = True + except: + pass diff --git a/spyder/utils/misc.py b/spyder/utils/misc.py index 2bc6922d69a..681534b0eb1 100644 --- a/spyder/utils/misc.py +++ b/spyder/utils/misc.py @@ -1,292 +1,292 @@ -# -*- coding: utf-8 -*- -# -# Copyright © Spyder Project Contributors -# Licensed under the terms of the MIT License -# (see spyder/__init__.py for details) - -"""Miscellaneous utilities""" - -import functools -import logging -import os -import os.path as osp -import re -import sys -import stat -import socket - -from spyder.py3compat import getcwd -from spyder.config.base import get_home_dir - - -logger = logging.getLogger(__name__) - - -def __remove_pyc_pyo(fname): - """Eventually remove .pyc and .pyo files associated to a Python script""" - if osp.splitext(fname)[1] == '.py': - for ending in ('c', 'o'): - if osp.exists(fname + ending): - os.remove(fname + ending) - - -def rename_file(source, dest): - """ - Rename file from *source* to *dest* - If file is a Python script, also rename .pyc and .pyo files if any - """ - os.rename(source, dest) - __remove_pyc_pyo(source) - - -def remove_file(fname): - """ - Remove file *fname* - If file is a Python script, also rename .pyc and .pyo files if any - """ - os.remove(fname) - __remove_pyc_pyo(fname) - - -def move_file(source, dest): - """ - Move file from *source* to *dest* - If file is a Python script, also rename .pyc and .pyo files if any - """ - import shutil - shutil.copy(source, dest) - remove_file(source) - - -def onerror(function, path, excinfo): - """Error handler for `shutil.rmtree`. - - If the error is due to an access error (read-only file), it - attempts to add write permission and then retries. - If the error is for another reason, it re-raises the error. - - Usage: `shutil.rmtree(path, onerror=onerror)""" - if not os.access(path, os.W_OK): - # Is the error an access error? - os.chmod(path, stat.S_IWUSR) - function(path) - else: - raise - - -def select_port(default_port=20128): - """Find and return a non used port""" - import socket - while True: - try: - sock = socket.socket(socket.AF_INET, - socket.SOCK_STREAM, - socket.IPPROTO_TCP) -# sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) - sock.bind(("127.0.0.1", default_port)) - except socket.error as _msg: # analysis:ignore - default_port += 1 - else: - break - finally: - sock.close() - sock = None - return default_port - - -def count_lines(path, extensions=None, excluded_dirnames=None): - """Return number of source code lines for all filenames in subdirectories - of *path* with names ending with *extensions* - Directory names *excluded_dirnames* will be ignored""" - if extensions is None: - extensions = ['.py', '.pyw', '.ipy', '.enaml', '.c', '.h', '.cpp', - '.hpp', '.inc', '.', '.hh', '.hxx', '.cc', '.cxx', - '.cl', '.f', '.for', '.f77', '.f90', '.f95', '.f2k', - '.f03', '.f08'] - if excluded_dirnames is None: - excluded_dirnames = ['build', 'dist', '.hg', '.svn'] - - def get_filelines(path): - dfiles, dlines = 0, 0 - if osp.splitext(path)[1] in extensions: - dfiles = 1 - with open(path, 'rb') as textfile: - dlines = len(textfile.read().strip().splitlines()) - return dfiles, dlines - lines = 0 - files = 0 - if osp.isdir(path): - for dirpath, dirnames, filenames in os.walk(path): - for d in dirnames[:]: - if d in excluded_dirnames: - dirnames.remove(d) - if excluded_dirnames is None or \ - osp.dirname(dirpath) not in excluded_dirnames: - for fname in filenames: - dfiles, dlines = get_filelines(osp.join(dirpath, fname)) - files += dfiles - lines += dlines - else: - dfiles, dlines = get_filelines(path) - files += dfiles - lines += dlines - return files, lines - - -def remove_backslashes(path): - """Remove backslashes in *path* - - For Windows platforms only. - Returns the path unchanged on other platforms. - - This is especially useful when formatting path strings on - Windows platforms for which folder paths may contain backslashes - and provoke unicode decoding errors in Python 3 (or in Python 2 - when future 'unicode_literals' symbol has been imported).""" - if os.name == 'nt': - # Removing trailing single backslash - if path.endswith('\\') and not path.endswith('\\\\'): - path = path[:-1] - # Replacing backslashes by slashes - path = path.replace('\\', '/') - path = path.replace('/\'', '\\\'') - return path - - -def get_error_match(text): - """Return error match""" - import re - return re.match(r' File "(.*)", line (\d*)', text) - - -def get_python_executable(): - """Return path to Spyder Python executable""" - executable = sys.executable.replace("pythonw.exe", "python.exe") - if executable.endswith("spyder.exe"): - # py2exe distribution - executable = "python.exe" - return executable - - -def monkeypatch_method(cls, patch_name): - # This function's code was inspired from the following thread: - # "[Python-Dev] Monkeypatching idioms -- elegant or ugly?" - # by Robert Brewer - # (Tue Jan 15 19:13:25 CET 2008) - """ - Add the decorated method to the given class; replace as needed. - - If the named method already exists on the given class, it will - be replaced, and a reference to the old method is created as - cls._old. If the "_old__" attribute - already exists, KeyError is raised. - """ - def decorator(func): - fname = func.__name__ - old_func = getattr(cls, fname, None) - if old_func is not None: - # Add the old func to a list of old funcs. - old_ref = "_old_%s_%s" % (patch_name, fname) - - old_attr = getattr(cls, old_ref, None) - if old_attr is None: - setattr(cls, old_ref, old_func) - else: - raise KeyError("%s.%s already exists." - % (cls.__name__, old_ref)) - setattr(cls, fname, func) - return func - return decorator - - -def is_python_script(fname): - """Is it a valid Python script?""" - return osp.isfile(fname) and fname.endswith(('.py', '.pyw', '.ipy')) - - -def abspardir(path): - """Return absolute parent dir""" - return osp.abspath(osp.join(path, os.pardir)) - - -def get_common_path(pathlist): - """Return common path for all paths in pathlist""" - common = osp.normpath(osp.commonprefix(pathlist)) - if len(common) > 1: - if not osp.isdir(common): - return abspardir(common) - else: - for path in pathlist: - if not osp.isdir(osp.join(common, path[len(common) + 1:])): - # `common` is not the real common prefix - return abspardir(common) - else: - return osp.abspath(common) - - -def memoize(obj): - """ - Memoize objects to trade memory for execution speed - - Use a limited size cache to store the value, which takes into account - The calling args and kwargs - - See https://wiki.python.org/moin/PythonDecoratorLibrary#Memoize - """ - cache = obj.cache = {} - - @functools.wraps(obj) - def memoizer(*args, **kwargs): - key = str(args) + str(kwargs) - if key not in cache: - cache[key] = obj(*args, **kwargs) - # only keep the most recent 100 entries - if len(cache) > 100: - cache.popitem(last=False) - return cache[key] - return memoizer - - -def getcwd_or_home(): - """Safe version of getcwd that will fallback to home user dir. - - This will catch the error raised when the current working directory - was removed for an external program. - """ - try: - return getcwd() - except OSError: - logger.debug("WARNING: Current working directory was deleted, " - "falling back to home dirertory") - return get_home_dir() - - -def regexp_error_msg(pattern): - """ - Return None if the pattern is a valid regular expression or - a string describing why the pattern is invalid. - """ - try: - re.compile(pattern) - except re.error as e: - return str(e) - return None - - -def check_connection_port(address, port): - """Verify if `port` is available in `address`.""" - # Create a TCP socket - s = socket.socket() - s.settimeout(2) - logger.debug("Attempting to connect to {} on port {}".format( - address, port)) - try: - s.connect((address, port)) - logger.debug("Connected to {} on port {}".format(address, port)) - return True - except socket.error as e: - logger.debug("Connection to {} on port {} failed: {}".format( - address, port, e)) - return False - finally: - s.close() +# -*- coding: utf-8 -*- +# +# Copyright © Spyder Project Contributors +# Licensed under the terms of the MIT License +# (see spyder/__init__.py for details) + +"""Miscellaneous utilities""" + +import functools +import logging +import os +import os.path as osp +import re +import sys +import stat +import socket + +from spyder.py3compat import getcwd +from spyder.config.base import get_home_dir + + +logger = logging.getLogger(__name__) + + +def __remove_pyc_pyo(fname): + """Eventually remove .pyc and .pyo files associated to a Python script""" + if osp.splitext(fname)[1] == '.py': + for ending in ('c', 'o'): + if osp.exists(fname + ending): + os.remove(fname + ending) + + +def rename_file(source, dest): + """ + Rename file from *source* to *dest* + If file is a Python script, also rename .pyc and .pyo files if any + """ + os.rename(source, dest) + __remove_pyc_pyo(source) + + +def remove_file(fname): + """ + Remove file *fname* + If file is a Python script, also rename .pyc and .pyo files if any + """ + os.remove(fname) + __remove_pyc_pyo(fname) + + +def move_file(source, dest): + """ + Move file from *source* to *dest* + If file is a Python script, also rename .pyc and .pyo files if any + """ + import shutil + shutil.copy(source, dest) + remove_file(source) + + +def onerror(function, path, excinfo): + """Error handler for `shutil.rmtree`. + + If the error is due to an access error (read-only file), it + attempts to add write permission and then retries. + If the error is for another reason, it re-raises the error. + + Usage: `shutil.rmtree(path, onerror=onerror)""" + if not os.access(path, os.W_OK): + # Is the error an access error? + os.chmod(path, stat.S_IWUSR) + function(path) + else: + raise + + +def select_port(default_port=20128): + """Find and return a non used port""" + import socket + while True: + try: + sock = socket.socket(socket.AF_INET, + socket.SOCK_STREAM, + socket.IPPROTO_TCP) +# sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + sock.bind(("127.0.0.1", default_port)) + except socket.error as _msg: # analysis:ignore + default_port += 1 + else: + break + finally: + sock.close() + sock = None + return default_port + + +def count_lines(path, extensions=None, excluded_dirnames=None): + """Return number of source code lines for all filenames in subdirectories + of *path* with names ending with *extensions* + Directory names *excluded_dirnames* will be ignored""" + if extensions is None: + extensions = ['.py', '.pyw', '.ipy', '.enaml', '.c', '.h', '.cpp', + '.hpp', '.inc', '.', '.hh', '.hxx', '.cc', '.cxx', + '.cl', '.f', '.for', '.f77', '.f90', '.f95', '.f2k', + '.f03', '.f08'] + if excluded_dirnames is None: + excluded_dirnames = ['build', 'dist', '.hg', '.svn'] + + def get_filelines(path): + dfiles, dlines = 0, 0 + if osp.splitext(path)[1] in extensions: + dfiles = 1 + with open(path, 'rb') as textfile: + dlines = len(textfile.read().strip().splitlines()) + return dfiles, dlines + lines = 0 + files = 0 + if osp.isdir(path): + for dirpath, dirnames, filenames in os.walk(path): + for d in dirnames[:]: + if d in excluded_dirnames: + dirnames.remove(d) + if excluded_dirnames is None or \ + osp.dirname(dirpath) not in excluded_dirnames: + for fname in filenames: + dfiles, dlines = get_filelines(osp.join(dirpath, fname)) + files += dfiles + lines += dlines + else: + dfiles, dlines = get_filelines(path) + files += dfiles + lines += dlines + return files, lines + + +def remove_backslashes(path): + """Remove backslashes in *path* + + For Windows platforms only. + Returns the path unchanged on other platforms. + + This is especially useful when formatting path strings on + Windows platforms for which folder paths may contain backslashes + and provoke unicode decoding errors in Python 3 (or in Python 2 + when future 'unicode_literals' symbol has been imported).""" + if os.name == 'nt': + # Removing trailing single backslash + if path.endswith('\\') and not path.endswith('\\\\'): + path = path[:-1] + # Replacing backslashes by slashes + path = path.replace('\\', '/') + path = path.replace('/\'', '\\\'') + return path + + +def get_error_match(text): + """Return error match""" + import re + return re.match(r' File "(.*)", line (\d*)', text) + + +def get_python_executable(): + """Return path to Spyder Python executable""" + executable = sys.executable.replace("pythonw.exe", "python.exe") + if executable.endswith("spyder.exe"): + # py2exe distribution + executable = "python.exe" + return executable + + +def monkeypatch_method(cls, patch_name): + # This function's code was inspired from the following thread: + # "[Python-Dev] Monkeypatching idioms -- elegant or ugly?" + # by Robert Brewer + # (Tue Jan 15 19:13:25 CET 2008) + """ + Add the decorated method to the given class; replace as needed. + + If the named method already exists on the given class, it will + be replaced, and a reference to the old method is created as + cls._old. If the "_old__" attribute + already exists, KeyError is raised. + """ + def decorator(func): + fname = func.__name__ + old_func = getattr(cls, fname, None) + if old_func is not None: + # Add the old func to a list of old funcs. + old_ref = "_old_%s_%s" % (patch_name, fname) + + old_attr = getattr(cls, old_ref, None) + if old_attr is None: + setattr(cls, old_ref, old_func) + else: + raise KeyError("%s.%s already exists." + % (cls.__name__, old_ref)) + setattr(cls, fname, func) + return func + return decorator + + +def is_python_script(fname): + """Is it a valid Python script?""" + return osp.isfile(fname) and fname.endswith(('.py', '.pyw', '.ipy')) + + +def abspardir(path): + """Return absolute parent dir""" + return osp.abspath(osp.join(path, os.pardir)) + + +def get_common_path(pathlist): + """Return common path for all paths in pathlist""" + common = osp.normpath(osp.commonprefix(pathlist)) + if len(common) > 1: + if not osp.isdir(common): + return abspardir(common) + else: + for path in pathlist: + if not osp.isdir(osp.join(common, path[len(common) + 1:])): + # `common` is not the real common prefix + return abspardir(common) + else: + return osp.abspath(common) + + +def memoize(obj): + """ + Memoize objects to trade memory for execution speed + + Use a limited size cache to store the value, which takes into account + The calling args and kwargs + + See https://wiki.python.org/moin/PythonDecoratorLibrary#Memoize + """ + cache = obj.cache = {} + + @functools.wraps(obj) + def memoizer(*args, **kwargs): + key = str(args) + str(kwargs) + if key not in cache: + cache[key] = obj(*args, **kwargs) + # only keep the most recent 100 entries + if len(cache) > 100: + cache.popitem(last=False) + return cache[key] + return memoizer + + +def getcwd_or_home(): + """Safe version of getcwd that will fallback to home user dir. + + This will catch the error raised when the current working directory + was removed for an external program. + """ + try: + return getcwd() + except OSError: + logger.debug("WARNING: Current working directory was deleted, " + "falling back to home dirertory") + return get_home_dir() + + +def regexp_error_msg(pattern): + """ + Return None if the pattern is a valid regular expression or + a string describing why the pattern is invalid. + """ + try: + re.compile(pattern) + except re.error as e: + return str(e) + return None + + +def check_connection_port(address, port): + """Verify if `port` is available in `address`.""" + # Create a TCP socket + s = socket.socket() + s.settimeout(2) + logger.debug("Attempting to connect to {} on port {}".format( + address, port)) + try: + s.connect((address, port)) + logger.debug("Connected to {} on port {}".format(address, port)) + return True + except socket.error as e: + logger.debug("Connection to {} on port {} failed: {}".format( + address, port, e)) + return False + finally: + s.close() diff --git a/spyder/utils/programs.py b/spyder/utils/programs.py index 27e52fe8108..3b96cb8a851 100644 --- a/spyder/utils/programs.py +++ b/spyder/utils/programs.py @@ -1,1069 +1,1069 @@ -# -*- coding: utf-8 -*- -# -# Copyright © Spyder Project Contributors -# Licensed under the terms of the MIT License -# (see spyder/__init__.py for details) - -"""Running programs utilities.""" - -from __future__ import print_function - -# Standard library imports -from ast import literal_eval -from getpass import getuser -from textwrap import dedent -import glob -import importlib -import itertools -import os -import os.path as osp -import re -import subprocess -import sys -import tempfile -import threading -import time - -# Third party imports -import pkg_resources -from pkg_resources import parse_version -import psutil - -# Local imports -from spyder.config.base import (running_under_pytest, get_home_dir, - running_in_mac_app) -from spyder.py3compat import is_text_string, to_text_string -from spyder.utils import encoding -from spyder.utils.misc import get_python_executable - -HERE = osp.abspath(osp.dirname(__file__)) - - -class ProgramError(Exception): - pass - - -def get_temp_dir(suffix=None): - """ - Return temporary Spyder directory, checking previously that it exists. - """ - to_join = [tempfile.gettempdir()] - - if os.name == 'nt': - to_join.append('spyder') - else: - username = encoding.to_unicode_from_fs(getuser()) - to_join.append('spyder-' + username) - - tempdir = osp.join(*to_join) - - if not osp.isdir(tempdir): - os.mkdir(tempdir) - - if suffix is not None: - to_join.append(suffix) - - tempdir = osp.join(*to_join) - - if not osp.isdir(tempdir): - os.mkdir(tempdir) - - return tempdir - - -def is_program_installed(basename): - """ - Return program absolute path if installed in PATH. - Otherwise, return None. - - Also searches specific platform dependent paths that are not already in - PATH. This permits general use without assuming user profiles are - sourced (e.g. .bash_Profile), such as when login shells are not used to - launch Spyder. - - On macOS systems, a .app is considered installed if it exists. - """ - home = get_home_dir() - req_paths = [] - if sys.platform == 'darwin': - if basename.endswith('.app') and osp.exists(basename): - return basename - - pyenv = [ - osp.join('/usr', 'local', 'bin'), - osp.join(home, '.pyenv', 'bin') - ] - - # Prioritize Anaconda before Miniconda; local before global. - a = [osp.join(home, 'opt'), '/opt'] - b = ['anaconda', 'miniconda', 'anaconda3', 'miniconda3'] - conda = [osp.join(*p, 'condabin') for p in itertools.product(a, b)] - - req_paths.extend(pyenv + conda) - - elif sys.platform.startswith('linux'): - pyenv = [ - osp.join('/usr', 'local', 'bin'), - osp.join(home, '.pyenv', 'bin') - ] - - a = [home, '/opt'] - b = ['anaconda', 'miniconda', 'anaconda3', 'miniconda3'] - conda = [osp.join(*p, 'condabin') for p in itertools.product(a, b)] - - req_paths.extend(pyenv + conda) - - elif os.name == 'nt': - pyenv = [osp.join(home, '.pyenv', 'pyenv-win', 'bin')] - - a = [home, 'C:\\', osp.join('C:\\', 'ProgramData')] - b = ['Anaconda', 'Miniconda', 'Anaconda3', 'Miniconda3'] - conda = [osp.join(*p, 'condabin') for p in itertools.product(a, b)] - - req_paths.extend(pyenv + conda) - - for path in os.environ['PATH'].split(os.pathsep) + req_paths: - abspath = osp.join(path, basename) - if osp.isfile(abspath): - return abspath - - -def find_program(basename): - """ - Find program in PATH and return absolute path - - Try adding .exe or .bat to basename on Windows platforms - (return None if not found) - """ - names = [basename] - if os.name == 'nt': - # Windows platforms - extensions = ('.exe', '.bat', '.cmd') - if not basename.endswith(extensions): - names = [basename+ext for ext in extensions]+[basename] - for name in names: - path = is_program_installed(name) - if path: - return path - - -def get_full_command_for_program(path): - """ - Return the list of tokens necessary to open the program - at a given path. - - On macOS systems, this function prefixes .app paths with - 'open -a', which is necessary to run the application. - - On all other OS's, this function has no effect. - - :str path: The path of the program to run. - :return: The list of tokens necessary to run the program. - """ - if sys.platform == 'darwin' and path.endswith('.app'): - return ['open', '-a', path] - return [path] - - -def alter_subprocess_kwargs_by_platform(**kwargs): - """ - Given a dict, populate kwargs to create a generally - useful default setup for running subprocess processes - on different platforms. For example, `close_fds` is - set on posix and creation of a new console window is - disabled on Windows. - - This function will alter the given kwargs and return - the modified dict. - """ - kwargs.setdefault('close_fds', os.name == 'posix') - if os.name == 'nt': - CONSOLE_CREATION_FLAGS = 0 # Default value - # See: https://msdn.microsoft.com/en-us/library/windows/desktop/ms684863%28v=vs.85%29.aspx - CREATE_NO_WINDOW = 0x08000000 - # We "or" them together - CONSOLE_CREATION_FLAGS |= CREATE_NO_WINDOW - kwargs.setdefault('creationflags', CONSOLE_CREATION_FLAGS) - - # ensure Windows subprocess environment has SYSTEMROOT - if kwargs.get('env') is not None: - # Is SYSTEMROOT, SYSTEMDRIVE in env? case insensitive - for env_var in ['SYSTEMROOT', 'SYSTEMDRIVE']: - if env_var not in map(str.upper, kwargs['env'].keys()): - # Add from os.environ - for k, v in os.environ.items(): - if env_var == k.upper(): - kwargs['env'].update({k: v}) - break # don't risk multiple values - else: - # linux and macOS - if kwargs.get('env') is not None: - if 'HOME' not in kwargs['env']: - kwargs['env'].update({'HOME': get_home_dir()}) - - return kwargs - - -def run_shell_command(cmdstr, **subprocess_kwargs): - """ - Execute the given shell command. - - Note that *args and **kwargs will be passed to the subprocess call. - - If 'shell' is given in subprocess_kwargs it must be True, - otherwise ProgramError will be raised. - . - If 'executable' is not given in subprocess_kwargs, it will - be set to the value of the SHELL environment variable. - - Note that stdin, stdout and stderr will be set by default - to PIPE unless specified in subprocess_kwargs. - - :str cmdstr: The string run as a shell command. - :subprocess_kwargs: These will be passed to subprocess.Popen. - """ - if 'shell' in subprocess_kwargs and not subprocess_kwargs['shell']: - raise ProgramError( - 'The "shell" kwarg may be omitted, but if ' - 'provided it must be True.') - else: - subprocess_kwargs['shell'] = True - - # Don't pass SHELL to subprocess on Windows because it makes this - # fumction fail in Git Bash (where SHELL is declared; other Windows - # shells don't set it). - if not os.name == 'nt': - if 'executable' not in subprocess_kwargs: - subprocess_kwargs['executable'] = os.getenv('SHELL') - - for stream in ['stdin', 'stdout', 'stderr']: - subprocess_kwargs.setdefault(stream, subprocess.PIPE) - subprocess_kwargs = alter_subprocess_kwargs_by_platform( - **subprocess_kwargs) - return subprocess.Popen(cmdstr, **subprocess_kwargs) - - -def run_program(program, args=None, **subprocess_kwargs): - """ - Run program in a separate process. - - NOTE: returns the process object created by - `subprocess.Popen()`. This can be used with - `proc.communicate()` for example. - - If 'shell' appears in the kwargs, it must be False, - otherwise ProgramError will be raised. - - If only the program name is given and not the full path, - a lookup will be performed to find the program. If the - lookup fails, ProgramError will be raised. - - Note that stdin, stdout and stderr will be set by default - to PIPE unless specified in subprocess_kwargs. - - :str program: The name of the program to run. - :list args: The program arguments. - :subprocess_kwargs: These will be passed to subprocess.Popen. - """ - if 'shell' in subprocess_kwargs and subprocess_kwargs['shell']: - raise ProgramError( - "This function is only for non-shell programs, " - "use run_shell_command() instead.") - fullcmd = find_program(program) - if not fullcmd: - raise ProgramError("Program %s was not found" % program) - # As per subprocess, we make a complete list of prog+args - fullcmd = get_full_command_for_program(fullcmd) + (args or []) - for stream in ['stdin', 'stdout', 'stderr']: - subprocess_kwargs.setdefault(stream, subprocess.PIPE) - subprocess_kwargs = alter_subprocess_kwargs_by_platform( - **subprocess_kwargs) - return subprocess.Popen(fullcmd, **subprocess_kwargs) - - -def parse_linux_desktop_entry(fpath): - """Load data from desktop entry with xdg specification.""" - from xdg.DesktopEntry import DesktopEntry - - try: - entry = DesktopEntry(fpath) - entry_data = {} - entry_data['name'] = entry.getName() - entry_data['icon_path'] = entry.getIcon() - entry_data['exec'] = entry.getExec() - entry_data['type'] = entry.getType() - entry_data['hidden'] = entry.getHidden() - entry_data['fpath'] = fpath - except Exception: - entry_data = { - 'name': '', - 'icon_path': '', - 'hidden': '', - 'exec': '', - 'type': '', - 'fpath': fpath - } - - return entry_data - - -def _get_mac_application_icon_path(app_bundle_path): - """Parse mac application bundle and return path for *.icns file.""" - import plistlib - contents_path = info_path = os.path.join(app_bundle_path, 'Contents') - info_path = os.path.join(contents_path, 'Info.plist') - - pl = {} - if os.path.isfile(info_path): - try: - # readPlist is deprecated but needed for py27 compat - pl = plistlib.readPlist(info_path) - except Exception: - pass - - icon_file = pl.get('CFBundleIconFile') - icon_path = None - if icon_file: - icon_path = os.path.join(contents_path, 'Resources', icon_file) - - # Some app bundles seem to list the icon name without extension - if not icon_path.endswith('.icns'): - icon_path = icon_path + '.icns' - - if not os.path.isfile(icon_path): - icon_path = None - - return icon_path - - -def get_username(): - """Return current session username.""" - if os.name == 'nt': - username = os.getlogin() - else: - import pwd - username = pwd.getpwuid(os.getuid())[0] - - return username - - -def _get_win_reg_info(key_path, hive, flag, subkeys): - """ - See: https://stackoverflow.com/q/53132434 - """ - import winreg - - reg = winreg.ConnectRegistry(None, hive) - software_list = [] - try: - key = winreg.OpenKey(reg, key_path, 0, winreg.KEY_READ | flag) - count_subkey = winreg.QueryInfoKey(key)[0] - - for index in range(count_subkey): - software = {} - try: - subkey_name = winreg.EnumKey(key, index) - if not (subkey_name.startswith('{') - and subkey_name.endswith('}')): - software['key'] = subkey_name - subkey = winreg.OpenKey(key, subkey_name) - for property in subkeys: - try: - value = winreg.QueryValueEx(subkey, property)[0] - software[property] = value - except EnvironmentError: - software[property] = '' - software_list.append(software) - except EnvironmentError: - continue - except Exception: - pass - - return software_list - - -def _clean_win_application_path(path): - """Normalize windows path and remove extra quotes.""" - path = path.replace('\\', '/').lower() - # Check for quotes at start and end - if path[0] == '"' and path[-1] == '"': - path = literal_eval(path) - return path - - -def _get_win_applications(): - """Return all system installed windows applications.""" - import winreg - - # See: - # https://docs.microsoft.com/en-us/windows/desktop/shell/app-registration - key_path = 'SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\App Paths' - - # Hive and flags - hfs = [ - (winreg.HKEY_LOCAL_MACHINE, winreg.KEY_WOW64_32KEY), - (winreg.HKEY_LOCAL_MACHINE, winreg.KEY_WOW64_64KEY), - (winreg.HKEY_CURRENT_USER, 0), - ] - subkeys = [None] - sort_key = 'key' - app_paths = {} - _apps = [_get_win_reg_info(key_path, hf[0], hf[1], subkeys) for hf in hfs] - software_list = itertools.chain(*_apps) - for software in sorted(software_list, key=lambda x: x[sort_key]): - if software[None]: - key = software['key'].capitalize().replace('.exe', '') - expanded_fpath = os.path.expandvars(software[None]) - expanded_fpath = _clean_win_application_path(expanded_fpath) - app_paths[key] = expanded_fpath - - # See: - # https://www.blog.pythonlibrary.org/2010/03/03/finding-installed-software-using-python/ - # https://stackoverflow.com/q/53132434 - key_path = 'SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall' - subkeys = ['DisplayName', 'InstallLocation', 'DisplayIcon'] - sort_key = 'DisplayName' - apps = {} - _apps = [_get_win_reg_info(key_path, hf[0], hf[1], subkeys) for hf in hfs] - software_list = itertools.chain(*_apps) - for software in sorted(software_list, key=lambda x: x[sort_key]): - location = software['InstallLocation'] - name = software['DisplayName'] - icon = software['DisplayIcon'] - key = software['key'] - if name and icon: - icon = icon.replace('"', '') - icon = icon.split(',')[0] - - if location == '' and icon: - location = os.path.dirname(icon) - - if not os.path.isfile(icon): - icon = '' - - if location and os.path.isdir(location): - files = [f for f in os.listdir(location) - if os.path.isfile(os.path.join(location, f))] - if files: - for fname in files: - fn_low = fname.lower() - valid_file = fn_low.endswith(('.exe', '.com', '.bat')) - if valid_file and not fn_low.startswith('unins'): - fpath = os.path.join(location, fname) - expanded_fpath = os.path.expandvars(fpath) - expanded_fpath = _clean_win_application_path( - expanded_fpath) - apps[name + ' (' + fname + ')'] = expanded_fpath - # Join data - values = list(zip(*apps.values()))[-1] - for name, fpath in app_paths.items(): - if fpath not in values: - apps[name] = fpath - - return apps - - -def _get_linux_applications(): - """Return all system installed linux applications.""" - # See: - # https://standards.freedesktop.org/desktop-entry-spec/desktop-entry-spec-latest.html - # https://askubuntu.com/q/433609 - apps = {} - desktop_app_paths = [ - '/usr/share/**/*.desktop', - '~/.local/share/**/*.desktop', - ] - all_entries_data = [] - for path in desktop_app_paths: - fpaths = glob.glob(path) - for fpath in fpaths: - entry_data = parse_linux_desktop_entry(fpath) - all_entries_data.append(entry_data) - - for entry_data in sorted(all_entries_data, key=lambda x: x['name']): - if not entry_data['hidden'] and entry_data['type'] == 'Application': - apps[entry_data['name']] = entry_data['fpath'] - - return apps - - -def _get_mac_applications(): - """Return all system installed osx applications.""" - apps = {} - app_folders = [ - '/**/*.app', - '/Users/{}/**/*.app'.format(get_username()) - ] - - fpaths = [] - for path in app_folders: - fpaths += glob.glob(path) - - for fpath in fpaths: - if os.path.isdir(fpath): - name = os.path.basename(fpath).split('.app')[0] - apps[name] = fpath - - return apps - - -def get_application_icon(fpath): - """Return application icon or default icon if not found.""" - from qtpy.QtGui import QIcon - from spyder.utils.icon_manager import ima - - if os.path.isfile(fpath) or os.path.isdir(fpath): - icon = ima.icon('no_match') - if sys.platform == 'darwin': - icon_path = _get_mac_application_icon_path(fpath) - if icon_path and os.path.isfile(icon_path): - icon = QIcon(icon_path) - elif os.name == 'nt': - pass - else: - entry_data = parse_linux_desktop_entry(fpath) - icon_path = entry_data['icon_path'] - if icon_path: - if os.path.isfile(icon_path): - icon = QIcon(icon_path) - else: - icon = QIcon.fromTheme(icon_path) - else: - icon = ima.icon('help') - - return icon - - -def get_installed_applications(): - """ - Return all system installed applications. - - The return value is a list of tuples where the first item is the icon path - and the second item is the program executable path. - """ - apps = {} - if sys.platform == 'darwin': - apps = _get_mac_applications() - elif os.name == 'nt': - apps = _get_win_applications() - else: - apps = _get_linux_applications() - - if sys.platform == 'darwin': - apps = {key: val for (key, val) in apps.items() if osp.isdir(val)} - else: - apps = {key: val for (key, val) in apps.items() if osp.isfile(val)} - - return apps - - -def open_files_with_application(app_path, fnames): - """ - Generalized method for opening files with a specific application. - - Returns a dictionary of the command used and the return code. - A code equal to 0 means the application executed successfully. - """ - return_codes = {} - - if os.name == 'nt': - fnames = [fname.replace('\\', '/') for fname in fnames] - - if sys.platform == 'darwin': - if not (app_path.endswith('.app') and os.path.isdir(app_path)): - raise ValueError('`app_path` must point to a valid OSX ' - 'application!') - cmd = ['open', '-a', app_path] + fnames - try: - return_code = subprocess.call(cmd) - except Exception: - return_code = 1 - return_codes[' '.join(cmd)] = return_code - elif os.name == 'nt': - if not (app_path.endswith(('.exe', '.bat', '.com', '.cmd')) - and os.path.isfile(app_path)): - raise ValueError('`app_path` must point to a valid Windows ' - 'executable!') - cmd = [app_path] + fnames - try: - return_code = subprocess.call(cmd) - except OSError: - return_code = 1 - return_codes[' '.join(cmd)] = return_code - else: - if not (app_path.endswith('.desktop') and os.path.isfile(app_path)): - raise ValueError('`app_path` must point to a valid Linux ' - 'application!') - - entry = parse_linux_desktop_entry(app_path) - app_path = entry['exec'] - multi = [] - extra = [] - if len(fnames) == 1: - fname = fnames[0] - if '%u' in app_path: - cmd = app_path.replace('%u', fname) - elif '%f' in app_path: - cmd = app_path.replace('%f', fname) - elif '%U' in app_path: - cmd = app_path.replace('%U', fname) - elif '%F' in app_path: - cmd = app_path.replace('%F', fname) - else: - cmd = app_path - extra = fnames - elif len(fnames) > 1: - if '%U' in app_path: - cmd = app_path.replace('%U', ' '.join(fnames)) - elif '%F' in app_path: - cmd = app_path.replace('%F', ' '.join(fnames)) - if '%u' in app_path: - for fname in fnames: - multi.append(app_path.replace('%u', fname)) - elif '%f' in app_path: - for fname in fnames: - multi.append(app_path.replace('%f', fname)) - else: - cmd = app_path - extra = fnames - - if multi: - for cmd in multi: - try: - return_code = subprocess.call([cmd], shell=True) - except Exception: - return_code = 1 - return_codes[cmd] = return_code - else: - try: - return_code = subprocess.call([cmd] + extra, shell=True) - except Exception: - return_code = 1 - return_codes[cmd] = return_code - - return return_codes - - -def python_script_exists(package=None, module=None): - """ - Return absolute path if Python script exists (otherwise, return None) - package=None -> module is in sys.path (standard library modules) - """ - assert module is not None - if package is None: - spec = importlib.util.find_spec(module) - if spec: - path = spec.origin - else: - path = None - else: - spec = importlib.util.find_spec(package) - if spec: - path = osp.join(spec.origin, module)+'.py' - else: - path = None - if path: - if not osp.isfile(path): - path += 'w' - if osp.isfile(path): - return path - - -def run_python_script(package=None, module=None, args=[], p_args=[]): - """ - Run Python script in a separate process - package=None -> module is in sys.path (standard library modules) - """ - assert module is not None - assert isinstance(args, (tuple, list)) and isinstance(p_args, (tuple, list)) - path = python_script_exists(package, module) - run_program(sys.executable, p_args + [path] + args) - - -def shell_split(text): - """ - Split the string `text` using shell-like syntax - - This avoids breaking single/double-quoted strings (e.g. containing - strings with spaces). This function is almost equivalent to the shlex.split - function (see standard library `shlex`) except that it is supporting - unicode strings (shlex does not support unicode until Python 2.7.3). - """ - assert is_text_string(text) # in case a QString is passed... - pattern = r'(\s+|(?': - return parse_version(actver) > parse_version(version) - elif cmp_op == '>=': - return parse_version(actver) >= parse_version(version) - elif cmp_op == '=': - return parse_version(actver) == parse_version(version) - elif cmp_op == '<': - return parse_version(actver) < parse_version(version) - elif cmp_op == '<=': - return parse_version(actver) <= parse_version(version) - else: - return False - except TypeError: - return True - - -def get_module_version(module_name): - """Return module version or None if version can't be retrieved.""" - mod = __import__(module_name) - ver = getattr(mod, '__version__', getattr(mod, 'VERSION', None)) - if not ver: - ver = get_package_version(module_name) - return ver - - -def get_package_version(package_name): - """Return package version or None if version can't be retrieved.""" - - # When support for Python 3.7 and below is dropped, this can be replaced - # with the built-in importlib.metadata.version - try: - ver = pkg_resources.get_distribution(package_name).version - return ver - except pkg_resources.DistributionNotFound: - return None - - -def is_module_installed(module_name, version=None, interpreter=None, - distribution_name=None): - """ - Return True if module ``module_name`` is installed - - If ``version`` is not None, checks that the module's installed version is - consistent with ``version``. The module must have an attribute named - '__version__' or 'VERSION'. - - version may start with =, >=, > or < to specify the exact requirement ; - multiple conditions may be separated by ';' (e.g. '>=0.13;<1.0') - - If ``interpreter`` is not None, checks if a module is installed with a - given ``version`` in the ``interpreter``'s environment. Otherwise checks - in Spyder's environment. - - ``distribution_name`` is the distribution name of a package. For instance, - for pylsp_black that name is python_lsp_black. - """ - if interpreter is not None: - if is_python_interpreter(interpreter): - cmd = dedent(""" - try: - import {} as mod - except Exception: - print('No Module') # spyder: test-skip - print(getattr(mod, '__version__', getattr(mod, 'VERSION', None))) # spyder: test-skip - """).format(module_name) - try: - # use clean environment - proc = run_program(interpreter, ['-c', cmd], env={}) - stdout, stderr = proc.communicate() - stdout = stdout.decode().strip() - except Exception: - return False - - if 'No Module' in stdout: - return False - elif stdout != 'None': - # the module is installed and it has a version attribute - module_version = stdout - else: - module_version = None - else: - # Try to not take a wrong decision if interpreter check fails - return True - else: - # interpreter is None, just get module version in Spyder environment - try: - module_version = get_module_version(module_name) - except Exception: - # Module is not installed - return False - - # This can happen if a package was not uninstalled correctly. For - # instance, if it's __pycache__ main directory is left behind. - try: - mod = __import__(module_name) - if not getattr(mod, '__file__', None): - return False - except Exception: - pass - - # Try to get the module version from its distribution name. For - # instance, pylsp_black doesn't have a version but that can be - # obtained from its distribution, called python_lsp_black. - if not module_version and distribution_name: - module_version = get_package_version(distribution_name) - - if version is None: - return True - else: - if ';' in version: - versions = version.split(';') - else: - versions = [version] - - output = True - for _ver in versions: - match = re.search(r'[0-9]', _ver) - assert match is not None, "Invalid version number" - symb = _ver[:match.start()] - if not symb: - symb = '=' - assert symb in ('>=', '>', '=', '<', '<='),\ - "Invalid version condition '%s'" % symb - ver = _ver[match.start():] - output = output and check_version(module_version, ver, symb) - return output - - -def is_python_interpreter_valid_name(filename): - """Check that the python interpreter file has a valid name.""" - pattern = r'.*python(\d\.?\d*)?(w)?(.exe)?$' - if re.match(pattern, filename, flags=re.I) is None: - return False - else: - return True - - -def is_python_interpreter(filename): - """Evaluate whether a file is a python interpreter or not.""" - # Must be imported here to avoid circular import - from spyder.utils.conda import is_conda_env - - real_filename = os.path.realpath(filename) # To follow symlink if existent - - if (not osp.isfile(real_filename) or - not is_python_interpreter_valid_name(real_filename)): - return False - - # File exists and has valid name - is_text_file = encoding.is_text_file(real_filename) - - if is_pythonw(real_filename): - if os.name == 'nt': - # pythonw is a binary on Windows - if not is_text_file: - return True - else: - return False - elif sys.platform == 'darwin': - # pythonw is a text file in Anaconda but a binary in - # the system - if is_conda_env(pyexec=real_filename) and is_text_file: - return True - elif not is_text_file: - return True - else: - return False - else: - # There's no pythonw in other systems - return False - elif is_text_file: - # At this point we can't have a text file - return False - else: - return check_python_help(real_filename) - - -def is_pythonw(filename): - """Check that the python interpreter has 'pythonw'.""" - pattern = r'.*python(\d\.?\d*)?w(.exe)?$' - if re.match(pattern, filename, flags=re.I) is None: - return False - else: - return True - - -def check_python_help(filename): - """Check that the python interpreter can compile and provide the zen.""" - try: - proc = run_program(filename, ['-c', 'import this'], env={}) - stdout, _ = proc.communicate() - stdout = to_text_string(stdout) - valid_lines = [ - 'Beautiful is better than ugly.', - 'Explicit is better than implicit.', - 'Simple is better than complex.', - 'Complex is better than complicated.', - ] - if all(line in stdout for line in valid_lines): - return True - else: - return False - except Exception: - return False - - -def is_spyder_process(pid): - """ - Test whether given PID belongs to a Spyder process. - - This is checked by testing the first three command line arguments. This - function returns a bool. If there is no process with this PID or its - command line cannot be accessed (perhaps because the process is owned by - another user), then the function returns False. - """ - try: - p = psutil.Process(int(pid)) - - # Valid names for main script - names = set(['spyder', 'spyder3', 'spyder.exe', 'spyder3.exe', - 'bootstrap.py', 'spyder-script.py', 'Spyder.launch.pyw']) - if running_under_pytest(): - names.add('runtests.py') - - # Check the first three command line arguments - arguments = set(os.path.basename(arg) for arg in p.cmdline()[:3]) - conditions = [names & arguments] - return any(conditions) - except (psutil.NoSuchProcess, psutil.AccessDenied): - return False - - -def get_interpreter_info(path): - """Return version information of the selected Python interpreter.""" - try: - out, __ = run_program(path, ['-V']).communicate() - out = out.decode() - except Exception: - out = '' - return out.strip() - - -def find_git(): - """Find git executable in the system.""" - if sys.platform == 'darwin': - proc = subprocess.run( - osp.join(HERE, "check-git.sh"), capture_output=True) - if proc.returncode != 0: - return None - return find_program('git') - else: - return find_program('git') +# -*- coding: utf-8 -*- +# +# Copyright © Spyder Project Contributors +# Licensed under the terms of the MIT License +# (see spyder/__init__.py for details) + +"""Running programs utilities.""" + +from __future__ import print_function + +# Standard library imports +from ast import literal_eval +from getpass import getuser +from textwrap import dedent +import glob +import importlib +import itertools +import os +import os.path as osp +import re +import subprocess +import sys +import tempfile +import threading +import time + +# Third party imports +import pkg_resources +from pkg_resources import parse_version +import psutil + +# Local imports +from spyder.config.base import (running_under_pytest, get_home_dir, + running_in_mac_app) +from spyder.py3compat import is_text_string, to_text_string +from spyder.utils import encoding +from spyder.utils.misc import get_python_executable + +HERE = osp.abspath(osp.dirname(__file__)) + + +class ProgramError(Exception): + pass + + +def get_temp_dir(suffix=None): + """ + Return temporary Spyder directory, checking previously that it exists. + """ + to_join = [tempfile.gettempdir()] + + if os.name == 'nt': + to_join.append('spyder') + else: + username = encoding.to_unicode_from_fs(getuser()) + to_join.append('spyder-' + username) + + tempdir = osp.join(*to_join) + + if not osp.isdir(tempdir): + os.mkdir(tempdir) + + if suffix is not None: + to_join.append(suffix) + + tempdir = osp.join(*to_join) + + if not osp.isdir(tempdir): + os.mkdir(tempdir) + + return tempdir + + +def is_program_installed(basename): + """ + Return program absolute path if installed in PATH. + Otherwise, return None. + + Also searches specific platform dependent paths that are not already in + PATH. This permits general use without assuming user profiles are + sourced (e.g. .bash_Profile), such as when login shells are not used to + launch Spyder. + + On macOS systems, a .app is considered installed if it exists. + """ + home = get_home_dir() + req_paths = [] + if sys.platform == 'darwin': + if basename.endswith('.app') and osp.exists(basename): + return basename + + pyenv = [ + osp.join('/usr', 'local', 'bin'), + osp.join(home, '.pyenv', 'bin') + ] + + # Prioritize Anaconda before Miniconda; local before global. + a = [osp.join(home, 'opt'), '/opt'] + b = ['anaconda', 'miniconda', 'anaconda3', 'miniconda3'] + conda = [osp.join(*p, 'condabin') for p in itertools.product(a, b)] + + req_paths.extend(pyenv + conda) + + elif sys.platform.startswith('linux'): + pyenv = [ + osp.join('/usr', 'local', 'bin'), + osp.join(home, '.pyenv', 'bin') + ] + + a = [home, '/opt'] + b = ['anaconda', 'miniconda', 'anaconda3', 'miniconda3'] + conda = [osp.join(*p, 'condabin') for p in itertools.product(a, b)] + + req_paths.extend(pyenv + conda) + + elif os.name == 'nt': + pyenv = [osp.join(home, '.pyenv', 'pyenv-win', 'bin')] + + a = [home, 'C:\\', osp.join('C:\\', 'ProgramData')] + b = ['Anaconda', 'Miniconda', 'Anaconda3', 'Miniconda3'] + conda = [osp.join(*p, 'condabin') for p in itertools.product(a, b)] + + req_paths.extend(pyenv + conda) + + for path in os.environ['PATH'].split(os.pathsep) + req_paths: + abspath = osp.join(path, basename) + if osp.isfile(abspath): + return abspath + + +def find_program(basename): + """ + Find program in PATH and return absolute path + + Try adding .exe or .bat to basename on Windows platforms + (return None if not found) + """ + names = [basename] + if os.name == 'nt': + # Windows platforms + extensions = ('.exe', '.bat', '.cmd') + if not basename.endswith(extensions): + names = [basename+ext for ext in extensions]+[basename] + for name in names: + path = is_program_installed(name) + if path: + return path + + +def get_full_command_for_program(path): + """ + Return the list of tokens necessary to open the program + at a given path. + + On macOS systems, this function prefixes .app paths with + 'open -a', which is necessary to run the application. + + On all other OS's, this function has no effect. + + :str path: The path of the program to run. + :return: The list of tokens necessary to run the program. + """ + if sys.platform == 'darwin' and path.endswith('.app'): + return ['open', '-a', path] + return [path] + + +def alter_subprocess_kwargs_by_platform(**kwargs): + """ + Given a dict, populate kwargs to create a generally + useful default setup for running subprocess processes + on different platforms. For example, `close_fds` is + set on posix and creation of a new console window is + disabled on Windows. + + This function will alter the given kwargs and return + the modified dict. + """ + kwargs.setdefault('close_fds', os.name == 'posix') + if os.name == 'nt': + CONSOLE_CREATION_FLAGS = 0 # Default value + # See: https://msdn.microsoft.com/en-us/library/windows/desktop/ms684863%28v=vs.85%29.aspx + CREATE_NO_WINDOW = 0x08000000 + # We "or" them together + CONSOLE_CREATION_FLAGS |= CREATE_NO_WINDOW + kwargs.setdefault('creationflags', CONSOLE_CREATION_FLAGS) + + # ensure Windows subprocess environment has SYSTEMROOT + if kwargs.get('env') is not None: + # Is SYSTEMROOT, SYSTEMDRIVE in env? case insensitive + for env_var in ['SYSTEMROOT', 'SYSTEMDRIVE']: + if env_var not in map(str.upper, kwargs['env'].keys()): + # Add from os.environ + for k, v in os.environ.items(): + if env_var == k.upper(): + kwargs['env'].update({k: v}) + break # don't risk multiple values + else: + # linux and macOS + if kwargs.get('env') is not None: + if 'HOME' not in kwargs['env']: + kwargs['env'].update({'HOME': get_home_dir()}) + + return kwargs + + +def run_shell_command(cmdstr, **subprocess_kwargs): + """ + Execute the given shell command. + + Note that *args and **kwargs will be passed to the subprocess call. + + If 'shell' is given in subprocess_kwargs it must be True, + otherwise ProgramError will be raised. + . + If 'executable' is not given in subprocess_kwargs, it will + be set to the value of the SHELL environment variable. + + Note that stdin, stdout and stderr will be set by default + to PIPE unless specified in subprocess_kwargs. + + :str cmdstr: The string run as a shell command. + :subprocess_kwargs: These will be passed to subprocess.Popen. + """ + if 'shell' in subprocess_kwargs and not subprocess_kwargs['shell']: + raise ProgramError( + 'The "shell" kwarg may be omitted, but if ' + 'provided it must be True.') + else: + subprocess_kwargs['shell'] = True + + # Don't pass SHELL to subprocess on Windows because it makes this + # fumction fail in Git Bash (where SHELL is declared; other Windows + # shells don't set it). + if not os.name == 'nt': + if 'executable' not in subprocess_kwargs: + subprocess_kwargs['executable'] = os.getenv('SHELL') + + for stream in ['stdin', 'stdout', 'stderr']: + subprocess_kwargs.setdefault(stream, subprocess.PIPE) + subprocess_kwargs = alter_subprocess_kwargs_by_platform( + **subprocess_kwargs) + return subprocess.Popen(cmdstr, **subprocess_kwargs) + + +def run_program(program, args=None, **subprocess_kwargs): + """ + Run program in a separate process. + + NOTE: returns the process object created by + `subprocess.Popen()`. This can be used with + `proc.communicate()` for example. + + If 'shell' appears in the kwargs, it must be False, + otherwise ProgramError will be raised. + + If only the program name is given and not the full path, + a lookup will be performed to find the program. If the + lookup fails, ProgramError will be raised. + + Note that stdin, stdout and stderr will be set by default + to PIPE unless specified in subprocess_kwargs. + + :str program: The name of the program to run. + :list args: The program arguments. + :subprocess_kwargs: These will be passed to subprocess.Popen. + """ + if 'shell' in subprocess_kwargs and subprocess_kwargs['shell']: + raise ProgramError( + "This function is only for non-shell programs, " + "use run_shell_command() instead.") + fullcmd = find_program(program) + if not fullcmd: + raise ProgramError("Program %s was not found" % program) + # As per subprocess, we make a complete list of prog+args + fullcmd = get_full_command_for_program(fullcmd) + (args or []) + for stream in ['stdin', 'stdout', 'stderr']: + subprocess_kwargs.setdefault(stream, subprocess.PIPE) + subprocess_kwargs = alter_subprocess_kwargs_by_platform( + **subprocess_kwargs) + return subprocess.Popen(fullcmd, **subprocess_kwargs) + + +def parse_linux_desktop_entry(fpath): + """Load data from desktop entry with xdg specification.""" + from xdg.DesktopEntry import DesktopEntry + + try: + entry = DesktopEntry(fpath) + entry_data = {} + entry_data['name'] = entry.getName() + entry_data['icon_path'] = entry.getIcon() + entry_data['exec'] = entry.getExec() + entry_data['type'] = entry.getType() + entry_data['hidden'] = entry.getHidden() + entry_data['fpath'] = fpath + except Exception: + entry_data = { + 'name': '', + 'icon_path': '', + 'hidden': '', + 'exec': '', + 'type': '', + 'fpath': fpath + } + + return entry_data + + +def _get_mac_application_icon_path(app_bundle_path): + """Parse mac application bundle and return path for *.icns file.""" + import plistlib + contents_path = info_path = os.path.join(app_bundle_path, 'Contents') + info_path = os.path.join(contents_path, 'Info.plist') + + pl = {} + if os.path.isfile(info_path): + try: + # readPlist is deprecated but needed for py27 compat + pl = plistlib.readPlist(info_path) + except Exception: + pass + + icon_file = pl.get('CFBundleIconFile') + icon_path = None + if icon_file: + icon_path = os.path.join(contents_path, 'Resources', icon_file) + + # Some app bundles seem to list the icon name without extension + if not icon_path.endswith('.icns'): + icon_path = icon_path + '.icns' + + if not os.path.isfile(icon_path): + icon_path = None + + return icon_path + + +def get_username(): + """Return current session username.""" + if os.name == 'nt': + username = os.getlogin() + else: + import pwd + username = pwd.getpwuid(os.getuid())[0] + + return username + + +def _get_win_reg_info(key_path, hive, flag, subkeys): + """ + See: https://stackoverflow.com/q/53132434 + """ + import winreg + + reg = winreg.ConnectRegistry(None, hive) + software_list = [] + try: + key = winreg.OpenKey(reg, key_path, 0, winreg.KEY_READ | flag) + count_subkey = winreg.QueryInfoKey(key)[0] + + for index in range(count_subkey): + software = {} + try: + subkey_name = winreg.EnumKey(key, index) + if not (subkey_name.startswith('{') + and subkey_name.endswith('}')): + software['key'] = subkey_name + subkey = winreg.OpenKey(key, subkey_name) + for property in subkeys: + try: + value = winreg.QueryValueEx(subkey, property)[0] + software[property] = value + except EnvironmentError: + software[property] = '' + software_list.append(software) + except EnvironmentError: + continue + except Exception: + pass + + return software_list + + +def _clean_win_application_path(path): + """Normalize windows path and remove extra quotes.""" + path = path.replace('\\', '/').lower() + # Check for quotes at start and end + if path[0] == '"' and path[-1] == '"': + path = literal_eval(path) + return path + + +def _get_win_applications(): + """Return all system installed windows applications.""" + import winreg + + # See: + # https://docs.microsoft.com/en-us/windows/desktop/shell/app-registration + key_path = 'SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\App Paths' + + # Hive and flags + hfs = [ + (winreg.HKEY_LOCAL_MACHINE, winreg.KEY_WOW64_32KEY), + (winreg.HKEY_LOCAL_MACHINE, winreg.KEY_WOW64_64KEY), + (winreg.HKEY_CURRENT_USER, 0), + ] + subkeys = [None] + sort_key = 'key' + app_paths = {} + _apps = [_get_win_reg_info(key_path, hf[0], hf[1], subkeys) for hf in hfs] + software_list = itertools.chain(*_apps) + for software in sorted(software_list, key=lambda x: x[sort_key]): + if software[None]: + key = software['key'].capitalize().replace('.exe', '') + expanded_fpath = os.path.expandvars(software[None]) + expanded_fpath = _clean_win_application_path(expanded_fpath) + app_paths[key] = expanded_fpath + + # See: + # https://www.blog.pythonlibrary.org/2010/03/03/finding-installed-software-using-python/ + # https://stackoverflow.com/q/53132434 + key_path = 'SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall' + subkeys = ['DisplayName', 'InstallLocation', 'DisplayIcon'] + sort_key = 'DisplayName' + apps = {} + _apps = [_get_win_reg_info(key_path, hf[0], hf[1], subkeys) for hf in hfs] + software_list = itertools.chain(*_apps) + for software in sorted(software_list, key=lambda x: x[sort_key]): + location = software['InstallLocation'] + name = software['DisplayName'] + icon = software['DisplayIcon'] + key = software['key'] + if name and icon: + icon = icon.replace('"', '') + icon = icon.split(',')[0] + + if location == '' and icon: + location = os.path.dirname(icon) + + if not os.path.isfile(icon): + icon = '' + + if location and os.path.isdir(location): + files = [f for f in os.listdir(location) + if os.path.isfile(os.path.join(location, f))] + if files: + for fname in files: + fn_low = fname.lower() + valid_file = fn_low.endswith(('.exe', '.com', '.bat')) + if valid_file and not fn_low.startswith('unins'): + fpath = os.path.join(location, fname) + expanded_fpath = os.path.expandvars(fpath) + expanded_fpath = _clean_win_application_path( + expanded_fpath) + apps[name + ' (' + fname + ')'] = expanded_fpath + # Join data + values = list(zip(*apps.values()))[-1] + for name, fpath in app_paths.items(): + if fpath not in values: + apps[name] = fpath + + return apps + + +def _get_linux_applications(): + """Return all system installed linux applications.""" + # See: + # https://standards.freedesktop.org/desktop-entry-spec/desktop-entry-spec-latest.html + # https://askubuntu.com/q/433609 + apps = {} + desktop_app_paths = [ + '/usr/share/**/*.desktop', + '~/.local/share/**/*.desktop', + ] + all_entries_data = [] + for path in desktop_app_paths: + fpaths = glob.glob(path) + for fpath in fpaths: + entry_data = parse_linux_desktop_entry(fpath) + all_entries_data.append(entry_data) + + for entry_data in sorted(all_entries_data, key=lambda x: x['name']): + if not entry_data['hidden'] and entry_data['type'] == 'Application': + apps[entry_data['name']] = entry_data['fpath'] + + return apps + + +def _get_mac_applications(): + """Return all system installed osx applications.""" + apps = {} + app_folders = [ + '/**/*.app', + '/Users/{}/**/*.app'.format(get_username()) + ] + + fpaths = [] + for path in app_folders: + fpaths += glob.glob(path) + + for fpath in fpaths: + if os.path.isdir(fpath): + name = os.path.basename(fpath).split('.app')[0] + apps[name] = fpath + + return apps + + +def get_application_icon(fpath): + """Return application icon or default icon if not found.""" + from qtpy.QtGui import QIcon + from spyder.utils.icon_manager import ima + + if os.path.isfile(fpath) or os.path.isdir(fpath): + icon = ima.icon('no_match') + if sys.platform == 'darwin': + icon_path = _get_mac_application_icon_path(fpath) + if icon_path and os.path.isfile(icon_path): + icon = QIcon(icon_path) + elif os.name == 'nt': + pass + else: + entry_data = parse_linux_desktop_entry(fpath) + icon_path = entry_data['icon_path'] + if icon_path: + if os.path.isfile(icon_path): + icon = QIcon(icon_path) + else: + icon = QIcon.fromTheme(icon_path) + else: + icon = ima.icon('help') + + return icon + + +def get_installed_applications(): + """ + Return all system installed applications. + + The return value is a list of tuples where the first item is the icon path + and the second item is the program executable path. + """ + apps = {} + if sys.platform == 'darwin': + apps = _get_mac_applications() + elif os.name == 'nt': + apps = _get_win_applications() + else: + apps = _get_linux_applications() + + if sys.platform == 'darwin': + apps = {key: val for (key, val) in apps.items() if osp.isdir(val)} + else: + apps = {key: val for (key, val) in apps.items() if osp.isfile(val)} + + return apps + + +def open_files_with_application(app_path, fnames): + """ + Generalized method for opening files with a specific application. + + Returns a dictionary of the command used and the return code. + A code equal to 0 means the application executed successfully. + """ + return_codes = {} + + if os.name == 'nt': + fnames = [fname.replace('\\', '/') for fname in fnames] + + if sys.platform == 'darwin': + if not (app_path.endswith('.app') and os.path.isdir(app_path)): + raise ValueError('`app_path` must point to a valid OSX ' + 'application!') + cmd = ['open', '-a', app_path] + fnames + try: + return_code = subprocess.call(cmd) + except Exception: + return_code = 1 + return_codes[' '.join(cmd)] = return_code + elif os.name == 'nt': + if not (app_path.endswith(('.exe', '.bat', '.com', '.cmd')) + and os.path.isfile(app_path)): + raise ValueError('`app_path` must point to a valid Windows ' + 'executable!') + cmd = [app_path] + fnames + try: + return_code = subprocess.call(cmd) + except OSError: + return_code = 1 + return_codes[' '.join(cmd)] = return_code + else: + if not (app_path.endswith('.desktop') and os.path.isfile(app_path)): + raise ValueError('`app_path` must point to a valid Linux ' + 'application!') + + entry = parse_linux_desktop_entry(app_path) + app_path = entry['exec'] + multi = [] + extra = [] + if len(fnames) == 1: + fname = fnames[0] + if '%u' in app_path: + cmd = app_path.replace('%u', fname) + elif '%f' in app_path: + cmd = app_path.replace('%f', fname) + elif '%U' in app_path: + cmd = app_path.replace('%U', fname) + elif '%F' in app_path: + cmd = app_path.replace('%F', fname) + else: + cmd = app_path + extra = fnames + elif len(fnames) > 1: + if '%U' in app_path: + cmd = app_path.replace('%U', ' '.join(fnames)) + elif '%F' in app_path: + cmd = app_path.replace('%F', ' '.join(fnames)) + if '%u' in app_path: + for fname in fnames: + multi.append(app_path.replace('%u', fname)) + elif '%f' in app_path: + for fname in fnames: + multi.append(app_path.replace('%f', fname)) + else: + cmd = app_path + extra = fnames + + if multi: + for cmd in multi: + try: + return_code = subprocess.call([cmd], shell=True) + except Exception: + return_code = 1 + return_codes[cmd] = return_code + else: + try: + return_code = subprocess.call([cmd] + extra, shell=True) + except Exception: + return_code = 1 + return_codes[cmd] = return_code + + return return_codes + + +def python_script_exists(package=None, module=None): + """ + Return absolute path if Python script exists (otherwise, return None) + package=None -> module is in sys.path (standard library modules) + """ + assert module is not None + if package is None: + spec = importlib.util.find_spec(module) + if spec: + path = spec.origin + else: + path = None + else: + spec = importlib.util.find_spec(package) + if spec: + path = osp.join(spec.origin, module)+'.py' + else: + path = None + if path: + if not osp.isfile(path): + path += 'w' + if osp.isfile(path): + return path + + +def run_python_script(package=None, module=None, args=[], p_args=[]): + """ + Run Python script in a separate process + package=None -> module is in sys.path (standard library modules) + """ + assert module is not None + assert isinstance(args, (tuple, list)) and isinstance(p_args, (tuple, list)) + path = python_script_exists(package, module) + run_program(sys.executable, p_args + [path] + args) + + +def shell_split(text): + """ + Split the string `text` using shell-like syntax + + This avoids breaking single/double-quoted strings (e.g. containing + strings with spaces). This function is almost equivalent to the shlex.split + function (see standard library `shlex`) except that it is supporting + unicode strings (shlex does not support unicode until Python 2.7.3). + """ + assert is_text_string(text) # in case a QString is passed... + pattern = r'(\s+|(?': + return parse_version(actver) > parse_version(version) + elif cmp_op == '>=': + return parse_version(actver) >= parse_version(version) + elif cmp_op == '=': + return parse_version(actver) == parse_version(version) + elif cmp_op == '<': + return parse_version(actver) < parse_version(version) + elif cmp_op == '<=': + return parse_version(actver) <= parse_version(version) + else: + return False + except TypeError: + return True + + +def get_module_version(module_name): + """Return module version or None if version can't be retrieved.""" + mod = __import__(module_name) + ver = getattr(mod, '__version__', getattr(mod, 'VERSION', None)) + if not ver: + ver = get_package_version(module_name) + return ver + + +def get_package_version(package_name): + """Return package version or None if version can't be retrieved.""" + + # When support for Python 3.7 and below is dropped, this can be replaced + # with the built-in importlib.metadata.version + try: + ver = pkg_resources.get_distribution(package_name).version + return ver + except pkg_resources.DistributionNotFound: + return None + + +def is_module_installed(module_name, version=None, interpreter=None, + distribution_name=None): + """ + Return True if module ``module_name`` is installed + + If ``version`` is not None, checks that the module's installed version is + consistent with ``version``. The module must have an attribute named + '__version__' or 'VERSION'. + + version may start with =, >=, > or < to specify the exact requirement ; + multiple conditions may be separated by ';' (e.g. '>=0.13;<1.0') + + If ``interpreter`` is not None, checks if a module is installed with a + given ``version`` in the ``interpreter``'s environment. Otherwise checks + in Spyder's environment. + + ``distribution_name`` is the distribution name of a package. For instance, + for pylsp_black that name is python_lsp_black. + """ + if interpreter is not None: + if is_python_interpreter(interpreter): + cmd = dedent(""" + try: + import {} as mod + except Exception: + print('No Module') # spyder: test-skip + print(getattr(mod, '__version__', getattr(mod, 'VERSION', None))) # spyder: test-skip + """).format(module_name) + try: + # use clean environment + proc = run_program(interpreter, ['-c', cmd], env={}) + stdout, stderr = proc.communicate() + stdout = stdout.decode().strip() + except Exception: + return False + + if 'No Module' in stdout: + return False + elif stdout != 'None': + # the module is installed and it has a version attribute + module_version = stdout + else: + module_version = None + else: + # Try to not take a wrong decision if interpreter check fails + return True + else: + # interpreter is None, just get module version in Spyder environment + try: + module_version = get_module_version(module_name) + except Exception: + # Module is not installed + return False + + # This can happen if a package was not uninstalled correctly. For + # instance, if it's __pycache__ main directory is left behind. + try: + mod = __import__(module_name) + if not getattr(mod, '__file__', None): + return False + except Exception: + pass + + # Try to get the module version from its distribution name. For + # instance, pylsp_black doesn't have a version but that can be + # obtained from its distribution, called python_lsp_black. + if not module_version and distribution_name: + module_version = get_package_version(distribution_name) + + if version is None: + return True + else: + if ';' in version: + versions = version.split(';') + else: + versions = [version] + + output = True + for _ver in versions: + match = re.search(r'[0-9]', _ver) + assert match is not None, "Invalid version number" + symb = _ver[:match.start()] + if not symb: + symb = '=' + assert symb in ('>=', '>', '=', '<', '<='),\ + "Invalid version condition '%s'" % symb + ver = _ver[match.start():] + output = output and check_version(module_version, ver, symb) + return output + + +def is_python_interpreter_valid_name(filename): + """Check that the python interpreter file has a valid name.""" + pattern = r'.*python(\d\.?\d*)?(w)?(.exe)?$' + if re.match(pattern, filename, flags=re.I) is None: + return False + else: + return True + + +def is_python_interpreter(filename): + """Evaluate whether a file is a python interpreter or not.""" + # Must be imported here to avoid circular import + from spyder.utils.conda import is_conda_env + + real_filename = os.path.realpath(filename) # To follow symlink if existent + + if (not osp.isfile(real_filename) or + not is_python_interpreter_valid_name(real_filename)): + return False + + # File exists and has valid name + is_text_file = encoding.is_text_file(real_filename) + + if is_pythonw(real_filename): + if os.name == 'nt': + # pythonw is a binary on Windows + if not is_text_file: + return True + else: + return False + elif sys.platform == 'darwin': + # pythonw is a text file in Anaconda but a binary in + # the system + if is_conda_env(pyexec=real_filename) and is_text_file: + return True + elif not is_text_file: + return True + else: + return False + else: + # There's no pythonw in other systems + return False + elif is_text_file: + # At this point we can't have a text file + return False + else: + return check_python_help(real_filename) + + +def is_pythonw(filename): + """Check that the python interpreter has 'pythonw'.""" + pattern = r'.*python(\d\.?\d*)?w(.exe)?$' + if re.match(pattern, filename, flags=re.I) is None: + return False + else: + return True + + +def check_python_help(filename): + """Check that the python interpreter can compile and provide the zen.""" + try: + proc = run_program(filename, ['-c', 'import this'], env={}) + stdout, _ = proc.communicate() + stdout = to_text_string(stdout) + valid_lines = [ + 'Beautiful is better than ugly.', + 'Explicit is better than implicit.', + 'Simple is better than complex.', + 'Complex is better than complicated.', + ] + if all(line in stdout for line in valid_lines): + return True + else: + return False + except Exception: + return False + + +def is_spyder_process(pid): + """ + Test whether given PID belongs to a Spyder process. + + This is checked by testing the first three command line arguments. This + function returns a bool. If there is no process with this PID or its + command line cannot be accessed (perhaps because the process is owned by + another user), then the function returns False. + """ + try: + p = psutil.Process(int(pid)) + + # Valid names for main script + names = set(['spyder', 'spyder3', 'spyder.exe', 'spyder3.exe', + 'bootstrap.py', 'spyder-script.py', 'Spyder.launch.pyw']) + if running_under_pytest(): + names.add('runtests.py') + + # Check the first three command line arguments + arguments = set(os.path.basename(arg) for arg in p.cmdline()[:3]) + conditions = [names & arguments] + return any(conditions) + except (psutil.NoSuchProcess, psutil.AccessDenied): + return False + + +def get_interpreter_info(path): + """Return version information of the selected Python interpreter.""" + try: + out, __ = run_program(path, ['-V']).communicate() + out = out.decode() + except Exception: + out = '' + return out.strip() + + +def find_git(): + """Find git executable in the system.""" + if sys.platform == 'darwin': + proc = subprocess.run( + osp.join(HERE, "check-git.sh"), capture_output=True) + if proc.returncode != 0: + return None + return find_program('git') + else: + return find_program('git') diff --git a/spyder/utils/qthelpers.py b/spyder/utils/qthelpers.py index b5f1a43b733..70f81e03f54 100644 --- a/spyder/utils/qthelpers.py +++ b/spyder/utils/qthelpers.py @@ -1,806 +1,806 @@ -# -*- coding: utf-8 -*- -# -# Copyright © Spyder Project Contributors -# Licensed under the terms of the MIT License -# (see spyder/__init__.py for details) - -"""Qt utilities.""" - -# Standard library imports -import functools -from math import pi -import logging -import os -import os.path as osp -import re -import sys -import types - -# Third party imports -from qtpy.compat import from_qvariant, to_qvariant -from qtpy.QtCore import (QEvent, QLibraryInfo, QLocale, QObject, Qt, QTimer, - QTranslator, QUrl, Signal, Slot) -from qtpy.QtGui import QDesktopServices, QKeyEvent, QKeySequence, QPixmap -from qtpy.QtWidgets import (QAction, QApplication, QDialog, QHBoxLayout, - QLabel, QLineEdit, QMenu, QPlainTextEdit, - QProxyStyle, QPushButton, QStyle, - QToolButton, QVBoxLayout, QWidget) - -# Local imports -from spyder.config.base import running_in_mac_app -from spyder.config.manager import CONF -from spyder.py3compat import configparser, is_text_string, to_text_string, PY2 -from spyder.utils.icon_manager import ima -from spyder.utils import programs -from spyder.utils.image_path_manager import get_image_path -from spyder.utils.palette import QStylePalette -from spyder.utils.registries import ACTION_REGISTRY, TOOLBUTTON_REGISTRY -from spyder.widgets.waitingspinner import QWaitingSpinner - -# Third party imports -if sys.platform == "darwin" and not running_in_mac_app(): - import applaunchservices as als - -if PY2: - from urllib import unquote -else: - from urllib.parse import unquote - - -# Note: How to redirect a signal from widget *a* to widget *b* ? -# ---- -# It has to be done manually: -# * typing 'SIGNAL("clicked()")' works -# * typing 'signalstr = "clicked()"; SIGNAL(signalstr)' won't work -# Here is an example of how to do it: -# (self.listwidget is widget *a* and self is widget *b*) -# self.connect(self.listwidget, SIGNAL('option_changed'), -# lambda *args: self.emit(SIGNAL('option_changed'), *args)) -logger = logging.getLogger(__name__) -MENU_SEPARATOR = None - - -def start_file(filename): - """ - Generalized os.startfile for all platforms supported by Qt - - This function is simply wrapping QDesktopServices.openUrl - - Returns True if successful, otherwise returns False. - """ - - # We need to use setUrl instead of setPath because this is the only - # cross-platform way to open external files. setPath fails completely on - # Mac and doesn't open non-ascii files on Linux. - # Fixes spyder-ide/spyder#740. - url = QUrl() - url.setUrl(filename) - return QDesktopServices.openUrl(url) - - -def get_image_label(name, default="not_found"): - """Return image inside a QLabel object""" - label = QLabel() - label.setPixmap(QPixmap(get_image_path(name, default))) - return label - - -def get_origin_filename(): - """Return the filename at the top of the stack""" - # Get top frame - f = sys._getframe() - while f.f_back is not None: - f = f.f_back - return f.f_code.co_filename - - -def qapplication(translate=True, test_time=3): - """ - Return QApplication instance - Creates it if it doesn't already exist - - test_time: Time to maintain open the application when testing. It's given - in seconds - """ - if sys.platform == "darwin": - SpyderApplication = MacApplication - else: - SpyderApplication = QApplication - - app = SpyderApplication.instance() - if app is None: - # Set Application name for Gnome 3 - # https://groups.google.com/forum/#!topic/pyside/24qxvwfrRDs - app = SpyderApplication(['Spyder']) - - # Set application name for KDE. See spyder-ide/spyder#2207. - app.setApplicationName('Spyder') - - if (sys.platform == "darwin" - and not running_in_mac_app() - and CONF.get('main', 'mac_open_file', False)): - # Register app if setting is set - register_app_launchservices() - - if translate: - install_translator(app) - - test_ci = os.environ.get('TEST_CI_WIDGETS', None) - if test_ci is not None: - timer_shutdown = QTimer(app) - timer_shutdown.timeout.connect(app.quit) - timer_shutdown.start(test_time * 1000) - return app - - -def file_uri(fname): - """Select the right file uri scheme according to the operating system""" - if os.name == 'nt': - # Local file - if re.search(r'^[a-zA-Z]:', fname): - return 'file:///' + fname - # UNC based path - else: - return 'file://' + fname - else: - return 'file://' + fname - - -QT_TRANSLATOR = None - - -def install_translator(qapp): - """Install Qt translator to the QApplication instance""" - global QT_TRANSLATOR - if QT_TRANSLATOR is None: - qt_translator = QTranslator() - if qt_translator.load( - "qt_" + QLocale.system().name(), - QLibraryInfo.location(QLibraryInfo.TranslationsPath)): - QT_TRANSLATOR = qt_translator # Keep reference alive - if QT_TRANSLATOR is not None: - qapp.installTranslator(QT_TRANSLATOR) - - -def keybinding(attr): - """Return keybinding""" - ks = getattr(QKeySequence, attr) - return from_qvariant(QKeySequence.keyBindings(ks)[0], str) - - -def _process_mime_path(path, extlist): - if path.startswith(r"file://"): - if os.name == 'nt': - # On Windows platforms, a local path reads: file:///c:/... - # and a UNC based path reads like: file://server/share - if path.startswith(r"file:///"): # this is a local path - path = path[8:] - else: # this is a unc path - path = path[5:] - else: - path = path[7:] - path = path.replace('\\', os.sep) # Transforming backslashes - if osp.exists(path): - if extlist is None or osp.splitext(path)[1] in extlist: - return path - - -def mimedata2url(source, extlist=None): - """ - Extract url list from MIME data - extlist: for example ('.py', '.pyw') - """ - pathlist = [] - if source.hasUrls(): - for url in source.urls(): - path = _process_mime_path( - unquote(to_text_string(url.toString())), extlist) - if path is not None: - pathlist.append(path) - elif source.hasText(): - for rawpath in to_text_string(source.text()).splitlines(): - path = _process_mime_path(rawpath, extlist) - if path is not None: - pathlist.append(path) - if pathlist: - return pathlist - - -def keyevent2tuple(event): - """Convert QKeyEvent instance into a tuple""" - return (event.type(), event.key(), event.modifiers(), event.text(), - event.isAutoRepeat(), event.count()) - - -def tuple2keyevent(past_event): - """Convert tuple into a QKeyEvent instance""" - return QKeyEvent(*past_event) - - -def restore_keyevent(event): - if isinstance(event, tuple): - _, key, modifiers, text, _, _ = event - event = tuple2keyevent(event) - else: - text = event.text() - modifiers = event.modifiers() - key = event.key() - ctrl = modifiers & Qt.ControlModifier - shift = modifiers & Qt.ShiftModifier - return event, text, key, ctrl, shift - - -def create_toolbutton(parent, text=None, shortcut=None, icon=None, tip=None, - toggled=None, triggered=None, - autoraise=True, text_beside_icon=False, - section=None, option=None, id_=None, plugin=None, - context_name=None, register_toolbutton=False): - """Create a QToolButton""" - button = QToolButton(parent) - if text is not None: - button.setText(text) - if icon is not None: - if is_text_string(icon): - icon = ima.get_icon(icon) - button.setIcon(icon) - if text is not None or tip is not None: - button.setToolTip(text if tip is None else tip) - if text_beside_icon: - button.setToolButtonStyle(Qt.ToolButtonTextBesideIcon) - button.setAutoRaise(autoraise) - if triggered is not None: - button.clicked.connect(triggered) - if toggled is not None: - setup_toggled_action(button, toggled, section, option) - if shortcut is not None: - button.setShortcut(shortcut) - if id_ is not None: - button.ID = id_ - - if register_toolbutton: - TOOLBUTTON_REGISTRY.register_reference( - button, id_, plugin, context_name) - return button - - -def create_waitspinner(size=32, n=11, parent=None): - """ - Create a wait spinner with the specified size built with n circling dots. - """ - dot_padding = 1 - - # To calculate the size of the dots, we need to solve the following - # system of two equations in two variables. - # (1) middle_circumference = pi * (size - dot_size) - # (2) middle_circumference = n * (dot_size + dot_padding) - dot_size = (pi * size - n * dot_padding) / (n + pi) - inner_radius = (size - 2 * dot_size) / 2 - - spinner = QWaitingSpinner(parent, centerOnParent=False) - spinner.setTrailSizeDecreasing(True) - spinner.setNumberOfLines(n) - spinner.setLineLength(dot_size) - spinner.setLineWidth(dot_size) - spinner.setInnerRadius(inner_radius) - spinner.setColor(QStylePalette.COLOR_TEXT_1) - - return spinner - - -def action2button(action, autoraise=True, text_beside_icon=False, parent=None, - icon=None): - """Create a QToolButton directly from a QAction object""" - if parent is None: - parent = action.parent() - button = QToolButton(parent) - button.setDefaultAction(action) - button.setAutoRaise(autoraise) - if text_beside_icon: - button.setToolButtonStyle(Qt.ToolButtonTextBesideIcon) - if icon: - action.setIcon(icon) - return button - - -def toggle_actions(actions, enable): - """Enable/disable actions""" - if actions is not None: - for action in actions: - if action is not None: - action.setEnabled(enable) - - -def create_action(parent, text, shortcut=None, icon=None, tip=None, - toggled=None, triggered=None, data=None, menurole=None, - context=Qt.WindowShortcut, option=None, section=None, - id_=None, plugin=None, context_name=None, - register_action=False, overwrite=False): - """Create a QAction""" - action = SpyderAction(text, parent, action_id=id_) - if triggered is not None: - action.triggered.connect(triggered) - if toggled is not None: - setup_toggled_action(action, toggled, section, option) - if icon is not None: - if is_text_string(icon): - icon = ima.get_icon(icon) - action.setIcon(icon) - if tip is not None: - action.setToolTip(tip) - action.setStatusTip(tip) - if data is not None: - action.setData(to_qvariant(data)) - if menurole is not None: - action.setMenuRole(menurole) - - # Workround for Mac because setting context=Qt.WidgetShortcut - # there doesn't have any effect - if sys.platform == 'darwin': - action._shown_shortcut = None - if context == Qt.WidgetShortcut: - if shortcut is not None: - action._shown_shortcut = shortcut - else: - # This is going to be filled by - # main.register_shortcut - action._shown_shortcut = 'missing' - else: - if shortcut is not None: - action.setShortcut(shortcut) - action.setShortcutContext(context) - else: - if shortcut is not None: - action.setShortcut(shortcut) - action.setShortcutContext(context) - - if register_action: - ACTION_REGISTRY.register_reference( - action, id_, plugin, context_name, overwrite) - return action - - -def setup_toggled_action(action, toggled, section, option): - """ - Setup a checkable action and wrap the toggle function to receive - configuration. - """ - toggled = wrap_toggled(toggled, section, option) - action.toggled.connect(toggled) - action.setCheckable(True) - if section is not None and option is not None: - CONF.observe_configuration(action, section, option) - add_configuration_update(action) - - -def wrap_toggled(toggled, section, option): - """Wrap a toggle function to set a value on a configuration option.""" - if section is not None and option is not None: - @functools.wraps(toggled) - def wrapped_toggled(value): - CONF.set(section, option, value, recursive_notification=True) - toggled(value) - return wrapped_toggled - return toggled - - -def add_configuration_update(action): - """Add on_configuration_change to a SpyderAction that depends on CONF.""" - - def on_configuration_change(self, _option, _section, value): - self.blockSignals(True) - self.setChecked(value) - self.blockSignals(False) - method = types.MethodType(on_configuration_change, action) - setattr(action, 'on_configuration_change', method) - - -def add_shortcut_to_tooltip(action, context, name): - """Add the shortcut associated with a given action to its tooltip""" - if not hasattr(action, '_tooltip_backup'): - # We store the original tooltip of the action without its associated - # shortcut so that we can update the tooltip properly if shortcuts - # are changed by the user over the course of the current session. - # See spyder-ide/spyder#10726. - action._tooltip_backup = action.toolTip() - - try: - # Some shortcuts might not be assigned so we need to catch the error - shortcut = CONF.get_shortcut(context=context, name=name) - except (configparser.NoSectionError, configparser.NoOptionError): - shortcut = None - - if shortcut: - keyseq = QKeySequence(shortcut) - # See: spyder-ide/spyder#12168 - string = keyseq.toString(QKeySequence.NativeText) - action.setToolTip(u'{0} ({1})'.format(action._tooltip_backup, string)) - - -def add_actions(target, actions, insert_before=None): - """Add actions to a QMenu or a QToolBar.""" - previous_action = None - target_actions = list(target.actions()) - if target_actions: - previous_action = target_actions[-1] - if previous_action.isSeparator(): - previous_action = None - for action in actions: - if (action is None) and (previous_action is not None): - if insert_before is None: - target.addSeparator() - else: - target.insertSeparator(insert_before) - elif isinstance(action, QMenu): - if insert_before is None: - target.addMenu(action) - else: - target.insertMenu(insert_before, action) - elif isinstance(action, QAction): - if insert_before is None: - # This is needed in order to ignore adding an action whose - # wrapped C/C++ object has been deleted. - # See spyder-ide/spyder#5074. - try: - target.addAction(action) - except RuntimeError: - continue - else: - target.insertAction(insert_before, action) - previous_action = action - - -def get_item_user_text(item): - """Get QTreeWidgetItem user role string""" - return from_qvariant(item.data(0, Qt.UserRole), to_text_string) - - -def set_item_user_text(item, text): - """Set QTreeWidgetItem user role string""" - item.setData(0, Qt.UserRole, to_qvariant(text)) - - -def create_bookmark_action(parent, url, title, icon=None, shortcut=None): - """Create bookmark action""" - - @Slot() - def open_url(): - return start_file(url) - - return create_action( parent, title, shortcut=shortcut, icon=icon, - triggered=open_url) - - -def create_module_bookmark_actions(parent, bookmarks): - """ - Create bookmark actions depending on module installation: - bookmarks = ((module_name, url, title), ...) - """ - actions = [] - for key, url, title in bookmarks: - # Create actions for scientific distros only if Spyder is installed - # under them - create_act = True - if key == 'winpython': - if not programs.is_module_installed(key): - create_act = False - if create_act: - act = create_bookmark_action(parent, url, title) - actions.append(act) - return actions - - -def create_program_action(parent, text, name, icon=None, nt_name=None): - """Create action to run a program""" - if is_text_string(icon): - icon = ima.get_icon(icon) - if os.name == 'nt' and nt_name is not None: - name = nt_name - path = programs.find_program(name) - if path is not None: - return create_action(parent, text, icon=icon, - triggered=lambda: programs.run_program(name)) - - -def create_python_script_action(parent, text, icon, package, module, args=[]): - """Create action to run a GUI based Python script""" - if is_text_string(icon): - icon = ima.get_icon(icon) - if programs.python_script_exists(package, module): - return create_action(parent, text, icon=icon, - triggered=lambda: - programs.run_python_script(package, module, args)) - - -class DialogManager(QObject): - """ - Object that keep references to non-modal dialog boxes for another QObject, - typically a QMainWindow or any kind of QWidget - """ - - def __init__(self): - QObject.__init__(self) - self.dialogs = {} - - def show(self, dialog): - """Generic method to show a non-modal dialog and keep reference - to the Qt C++ object""" - for dlg in list(self.dialogs.values()): - if to_text_string(dlg.windowTitle()) \ - == to_text_string(dialog.windowTitle()): - dlg.show() - dlg.raise_() - break - else: - dialog.show() - self.dialogs[id(dialog)] = dialog - dialog.accepted.connect( - lambda eid=id(dialog): self.dialog_finished(eid)) - dialog.rejected.connect( - lambda eid=id(dialog): self.dialog_finished(eid)) - - def dialog_finished(self, dialog_id): - """Manage non-modal dialog boxes""" - return self.dialogs.pop(dialog_id) - - def close_all(self): - """Close all opened dialog boxes""" - for dlg in list(self.dialogs.values()): - dlg.reject() - - -def get_filetype_icon(fname): - """Return file type icon""" - ext = osp.splitext(fname)[1] - if ext.startswith('.'): - ext = ext[1:] - return ima.get_icon("%s.png" % ext, ima.icon('FileIcon')) - - -class SpyderAction(QAction): - """Spyder QAction class wrapper to handle cross platform patches.""" - - def __init__(self, *args, action_id=None, **kwargs): - """Spyder QAction class wrapper to handle cross platform patches.""" - super(SpyderAction, self).__init__(*args, **kwargs) - self.action_id = action_id - if sys.platform == "darwin": - self.setIconVisibleInMenu(False) - - def __str__(self): - return "SpyderAction('{0}')".format(self.text()) - - def __repr__(self): - return "SpyderAction('{0}')".format(self.text()) - - -class ShowStdIcons(QWidget): - """ - Dialog showing standard icons - """ - - def __init__(self, parent): - QWidget.__init__(self, parent) - layout = QHBoxLayout() - row_nb = 14 - cindex = 0 - for child in dir(QStyle): - if child.startswith('SP_'): - if cindex == 0: - col_layout = QVBoxLayout() - icon_layout = QHBoxLayout() - icon = ima.get_std_icon(child) - label = QLabel() - label.setPixmap(icon.pixmap(32, 32)) - icon_layout.addWidget(label) - icon_layout.addWidget(QLineEdit(child.replace('SP_', ''))) - col_layout.addLayout(icon_layout) - cindex = (cindex + 1) % row_nb - if cindex == 0: - layout.addLayout(col_layout) - self.setLayout(layout) - self.setWindowTitle('Standard Platform Icons') - self.setWindowIcon(ima.get_std_icon('TitleBarMenuButton')) - - -def show_std_icons(): - """ - Show all standard Icons - """ - app = qapplication() - dialog = ShowStdIcons(None) - dialog.show() - sys.exit(app.exec_()) - - -def calc_tools_spacing(tools_layout): - """ - Return a spacing (int) or None if we don't have the appropriate metrics - to calculate the spacing. - - We're trying to adapt the spacing below the tools_layout spacing so that - the main_widget has the same vertical position as the editor widgets - (which have tabs above). - - The required spacing is - - spacing = tabbar_height - tools_height + offset - - where the tabbar_heights were empirically determined for a combination of - operating systems and styles. Offsets were manually adjusted, so that the - heights of main_widgets and editor widgets match. This is probably - caused by a still not understood element of the layout and style metrics. - """ - metrics = { # (tabbar_height, offset) - 'nt.fusion': (32, 0), - 'nt.windowsvista': (21, 3), - 'nt.windowsxp': (24, 0), - 'nt.windows': (21, 3), - 'posix.breeze': (28, -1), - 'posix.oxygen': (38, -2), - 'posix.qtcurve': (27, 0), - 'posix.windows': (26, 0), - 'posix.fusion': (32, 0), - } - - style_name = qapplication().style().property('name') - key = '%s.%s' % (os.name, style_name) - - if key in metrics: - tabbar_height, offset = metrics[key] - tools_height = tools_layout.sizeHint().height() - spacing = tabbar_height - tools_height + offset - return max(spacing, 0) - - -def create_plugin_layout(tools_layout, main_widget=None): - """ - Returns a layout for a set of controls above a main widget. This is a - standard layout for many plugin panes (even though, it's currently - more often applied not to the pane itself but with in the one widget - contained in the pane. - - tools_layout: a layout containing the top toolbar - main_widget: the main widget. Can be None, if you want to add this - manually later on. - """ - layout = QVBoxLayout() - layout.setContentsMargins(0, 0, 0, 0) - spacing = calc_tools_spacing(tools_layout) - if spacing is not None: - layout.setSpacing(spacing) - - layout.addLayout(tools_layout) - if main_widget is not None: - layout.addWidget(main_widget) - return layout - - -def set_menu_icons(menu, state): - """Show/hide icons for menu actions.""" - menu_actions = menu.actions() - for action in menu_actions: - try: - if action.menu() is not None: - # This is submenu, so we need to call this again - set_menu_icons(action.menu(), state) - elif action.isSeparator(): - continue - else: - action.setIconVisibleInMenu(state) - except RuntimeError: - continue - - -class SpyderProxyStyle(QProxyStyle): - """Style proxy to adjust qdarkstyle issues.""" - - def styleHint(self, hint, option=0, widget=0, returnData=0): - """Override Qt method.""" - if hint == QStyle.SH_ComboBox_Popup: - # Disable combo-box popup top & bottom areas - # See: https://stackoverflow.com/a/21019371 - return 0 - - return QProxyStyle.styleHint(self, hint, option, widget, returnData) - - -class QInputDialogMultiline(QDialog): - """ - Build a replica interface of QInputDialog.getMultilineText. - - Based on: https://stackoverflow.com/a/58823967 - """ - - def __init__(self, parent, title, label, text='', **kwargs): - super(QInputDialogMultiline, self).__init__(parent, **kwargs) - if title is not None: - self.setWindowTitle(title) - - self.setLayout(QVBoxLayout()) - self.layout().addWidget(QLabel(label)) - self.text_edit = QPlainTextEdit() - self.layout().addWidget(self.text_edit) - - button_layout = QHBoxLayout() - button_layout.addStretch() - ok_button = QPushButton('OK') - button_layout.addWidget(ok_button) - cancel_button = QPushButton('Cancel') - button_layout.addWidget(cancel_button) - self.layout().addLayout(button_layout) - - self.text_edit.setPlainText(text) - ok_button.clicked.connect(self.accept) - cancel_button.clicked.connect(self.reject) - - -# ============================================================================= -# Only for macOS -# ============================================================================= -class MacApplication(QApplication): - """Subclass to be able to open external files with our Mac app""" - sig_open_external_file = Signal(str) - - def __init__(self, *args): - QApplication.__init__(self, *args) - self._never_shown = True - self._has_started = False - self._pending_file_open = [] - self._original_handlers = {} - - def event(self, event): - if event.type() == QEvent.FileOpen: - fname = str(event.file()) - if sys.argv and sys.argv[0] == fname: - # Ignore requests to open own script - # Later, mainwindow.initialize() will set sys.argv[0] to '' - pass - elif self._has_started: - self.sig_open_external_file.emit(fname) - else: - self._pending_file_open.append(fname) - return QApplication.event(self, event) - - -def restore_launchservices(): - """Restore LaunchServices to the previous state""" - app = QApplication.instance() - for key, handler in app._original_handlers.items(): - UTI, role = key - als.set_UTI_handler(UTI, role, handler) - - -def register_app_launchservices( - uniform_type_identifier="public.python-script", - role='editor'): - """ - Register app to the Apple launch services so it can open Python files - """ - app = QApplication.instance() - - old_handler = als.get_UTI_handler(uniform_type_identifier, role) - - app._original_handlers[(uniform_type_identifier, role)] = old_handler - - # Restore previous handle when quitting - app.aboutToQuit.connect(restore_launchservices) - - if not app._never_shown: - bundle_identifier = als.get_bundle_identifier() - als.set_UTI_handler( - uniform_type_identifier, role, bundle_identifier) - return - - # Wait to be visible to set ourselves as the UTI handler - def handle_applicationStateChanged(state): - if state == Qt.ApplicationActive and app._never_shown: - app._never_shown = False - bundle_identifier = als.get_bundle_identifier() - als.set_UTI_handler( - uniform_type_identifier, role, bundle_identifier) - - app.applicationStateChanged.connect(handle_applicationStateChanged) - - -if __name__ == "__main__": - show_std_icons() +# -*- coding: utf-8 -*- +# +# Copyright © Spyder Project Contributors +# Licensed under the terms of the MIT License +# (see spyder/__init__.py for details) + +"""Qt utilities.""" + +# Standard library imports +import functools +from math import pi +import logging +import os +import os.path as osp +import re +import sys +import types + +# Third party imports +from qtpy.compat import from_qvariant, to_qvariant +from qtpy.QtCore import (QEvent, QLibraryInfo, QLocale, QObject, Qt, QTimer, + QTranslator, QUrl, Signal, Slot) +from qtpy.QtGui import QDesktopServices, QKeyEvent, QKeySequence, QPixmap +from qtpy.QtWidgets import (QAction, QApplication, QDialog, QHBoxLayout, + QLabel, QLineEdit, QMenu, QPlainTextEdit, + QProxyStyle, QPushButton, QStyle, + QToolButton, QVBoxLayout, QWidget) + +# Local imports +from spyder.config.base import running_in_mac_app +from spyder.config.manager import CONF +from spyder.py3compat import configparser, is_text_string, to_text_string, PY2 +from spyder.utils.icon_manager import ima +from spyder.utils import programs +from spyder.utils.image_path_manager import get_image_path +from spyder.utils.palette import QStylePalette +from spyder.utils.registries import ACTION_REGISTRY, TOOLBUTTON_REGISTRY +from spyder.widgets.waitingspinner import QWaitingSpinner + +# Third party imports +if sys.platform == "darwin" and not running_in_mac_app(): + import applaunchservices as als + +if PY2: + from urllib import unquote +else: + from urllib.parse import unquote + + +# Note: How to redirect a signal from widget *a* to widget *b* ? +# ---- +# It has to be done manually: +# * typing 'SIGNAL("clicked()")' works +# * typing 'signalstr = "clicked()"; SIGNAL(signalstr)' won't work +# Here is an example of how to do it: +# (self.listwidget is widget *a* and self is widget *b*) +# self.connect(self.listwidget, SIGNAL('option_changed'), +# lambda *args: self.emit(SIGNAL('option_changed'), *args)) +logger = logging.getLogger(__name__) +MENU_SEPARATOR = None + + +def start_file(filename): + """ + Generalized os.startfile for all platforms supported by Qt + + This function is simply wrapping QDesktopServices.openUrl + + Returns True if successful, otherwise returns False. + """ + + # We need to use setUrl instead of setPath because this is the only + # cross-platform way to open external files. setPath fails completely on + # Mac and doesn't open non-ascii files on Linux. + # Fixes spyder-ide/spyder#740. + url = QUrl() + url.setUrl(filename) + return QDesktopServices.openUrl(url) + + +def get_image_label(name, default="not_found"): + """Return image inside a QLabel object""" + label = QLabel() + label.setPixmap(QPixmap(get_image_path(name, default))) + return label + + +def get_origin_filename(): + """Return the filename at the top of the stack""" + # Get top frame + f = sys._getframe() + while f.f_back is not None: + f = f.f_back + return f.f_code.co_filename + + +def qapplication(translate=True, test_time=3): + """ + Return QApplication instance + Creates it if it doesn't already exist + + test_time: Time to maintain open the application when testing. It's given + in seconds + """ + if sys.platform == "darwin": + SpyderApplication = MacApplication + else: + SpyderApplication = QApplication + + app = SpyderApplication.instance() + if app is None: + # Set Application name for Gnome 3 + # https://groups.google.com/forum/#!topic/pyside/24qxvwfrRDs + app = SpyderApplication(['Spyder']) + + # Set application name for KDE. See spyder-ide/spyder#2207. + app.setApplicationName('Spyder') + + if (sys.platform == "darwin" + and not running_in_mac_app() + and CONF.get('main', 'mac_open_file', False)): + # Register app if setting is set + register_app_launchservices() + + if translate: + install_translator(app) + + test_ci = os.environ.get('TEST_CI_WIDGETS', None) + if test_ci is not None: + timer_shutdown = QTimer(app) + timer_shutdown.timeout.connect(app.quit) + timer_shutdown.start(test_time * 1000) + return app + + +def file_uri(fname): + """Select the right file uri scheme according to the operating system""" + if os.name == 'nt': + # Local file + if re.search(r'^[a-zA-Z]:', fname): + return 'file:///' + fname + # UNC based path + else: + return 'file://' + fname + else: + return 'file://' + fname + + +QT_TRANSLATOR = None + + +def install_translator(qapp): + """Install Qt translator to the QApplication instance""" + global QT_TRANSLATOR + if QT_TRANSLATOR is None: + qt_translator = QTranslator() + if qt_translator.load( + "qt_" + QLocale.system().name(), + QLibraryInfo.location(QLibraryInfo.TranslationsPath)): + QT_TRANSLATOR = qt_translator # Keep reference alive + if QT_TRANSLATOR is not None: + qapp.installTranslator(QT_TRANSLATOR) + + +def keybinding(attr): + """Return keybinding""" + ks = getattr(QKeySequence, attr) + return from_qvariant(QKeySequence.keyBindings(ks)[0], str) + + +def _process_mime_path(path, extlist): + if path.startswith(r"file://"): + if os.name == 'nt': + # On Windows platforms, a local path reads: file:///c:/... + # and a UNC based path reads like: file://server/share + if path.startswith(r"file:///"): # this is a local path + path = path[8:] + else: # this is a unc path + path = path[5:] + else: + path = path[7:] + path = path.replace('\\', os.sep) # Transforming backslashes + if osp.exists(path): + if extlist is None or osp.splitext(path)[1] in extlist: + return path + + +def mimedata2url(source, extlist=None): + """ + Extract url list from MIME data + extlist: for example ('.py', '.pyw') + """ + pathlist = [] + if source.hasUrls(): + for url in source.urls(): + path = _process_mime_path( + unquote(to_text_string(url.toString())), extlist) + if path is not None: + pathlist.append(path) + elif source.hasText(): + for rawpath in to_text_string(source.text()).splitlines(): + path = _process_mime_path(rawpath, extlist) + if path is not None: + pathlist.append(path) + if pathlist: + return pathlist + + +def keyevent2tuple(event): + """Convert QKeyEvent instance into a tuple""" + return (event.type(), event.key(), event.modifiers(), event.text(), + event.isAutoRepeat(), event.count()) + + +def tuple2keyevent(past_event): + """Convert tuple into a QKeyEvent instance""" + return QKeyEvent(*past_event) + + +def restore_keyevent(event): + if isinstance(event, tuple): + _, key, modifiers, text, _, _ = event + event = tuple2keyevent(event) + else: + text = event.text() + modifiers = event.modifiers() + key = event.key() + ctrl = modifiers & Qt.ControlModifier + shift = modifiers & Qt.ShiftModifier + return event, text, key, ctrl, shift + + +def create_toolbutton(parent, text=None, shortcut=None, icon=None, tip=None, + toggled=None, triggered=None, + autoraise=True, text_beside_icon=False, + section=None, option=None, id_=None, plugin=None, + context_name=None, register_toolbutton=False): + """Create a QToolButton""" + button = QToolButton(parent) + if text is not None: + button.setText(text) + if icon is not None: + if is_text_string(icon): + icon = ima.get_icon(icon) + button.setIcon(icon) + if text is not None or tip is not None: + button.setToolTip(text if tip is None else tip) + if text_beside_icon: + button.setToolButtonStyle(Qt.ToolButtonTextBesideIcon) + button.setAutoRaise(autoraise) + if triggered is not None: + button.clicked.connect(triggered) + if toggled is not None: + setup_toggled_action(button, toggled, section, option) + if shortcut is not None: + button.setShortcut(shortcut) + if id_ is not None: + button.ID = id_ + + if register_toolbutton: + TOOLBUTTON_REGISTRY.register_reference( + button, id_, plugin, context_name) + return button + + +def create_waitspinner(size=32, n=11, parent=None): + """ + Create a wait spinner with the specified size built with n circling dots. + """ + dot_padding = 1 + + # To calculate the size of the dots, we need to solve the following + # system of two equations in two variables. + # (1) middle_circumference = pi * (size - dot_size) + # (2) middle_circumference = n * (dot_size + dot_padding) + dot_size = (pi * size - n * dot_padding) / (n + pi) + inner_radius = (size - 2 * dot_size) / 2 + + spinner = QWaitingSpinner(parent, centerOnParent=False) + spinner.setTrailSizeDecreasing(True) + spinner.setNumberOfLines(n) + spinner.setLineLength(dot_size) + spinner.setLineWidth(dot_size) + spinner.setInnerRadius(inner_radius) + spinner.setColor(QStylePalette.COLOR_TEXT_1) + + return spinner + + +def action2button(action, autoraise=True, text_beside_icon=False, parent=None, + icon=None): + """Create a QToolButton directly from a QAction object""" + if parent is None: + parent = action.parent() + button = QToolButton(parent) + button.setDefaultAction(action) + button.setAutoRaise(autoraise) + if text_beside_icon: + button.setToolButtonStyle(Qt.ToolButtonTextBesideIcon) + if icon: + action.setIcon(icon) + return button + + +def toggle_actions(actions, enable): + """Enable/disable actions""" + if actions is not None: + for action in actions: + if action is not None: + action.setEnabled(enable) + + +def create_action(parent, text, shortcut=None, icon=None, tip=None, + toggled=None, triggered=None, data=None, menurole=None, + context=Qt.WindowShortcut, option=None, section=None, + id_=None, plugin=None, context_name=None, + register_action=False, overwrite=False): + """Create a QAction""" + action = SpyderAction(text, parent, action_id=id_) + if triggered is not None: + action.triggered.connect(triggered) + if toggled is not None: + setup_toggled_action(action, toggled, section, option) + if icon is not None: + if is_text_string(icon): + icon = ima.get_icon(icon) + action.setIcon(icon) + if tip is not None: + action.setToolTip(tip) + action.setStatusTip(tip) + if data is not None: + action.setData(to_qvariant(data)) + if menurole is not None: + action.setMenuRole(menurole) + + # Workround for Mac because setting context=Qt.WidgetShortcut + # there doesn't have any effect + if sys.platform == 'darwin': + action._shown_shortcut = None + if context == Qt.WidgetShortcut: + if shortcut is not None: + action._shown_shortcut = shortcut + else: + # This is going to be filled by + # main.register_shortcut + action._shown_shortcut = 'missing' + else: + if shortcut is not None: + action.setShortcut(shortcut) + action.setShortcutContext(context) + else: + if shortcut is not None: + action.setShortcut(shortcut) + action.setShortcutContext(context) + + if register_action: + ACTION_REGISTRY.register_reference( + action, id_, plugin, context_name, overwrite) + return action + + +def setup_toggled_action(action, toggled, section, option): + """ + Setup a checkable action and wrap the toggle function to receive + configuration. + """ + toggled = wrap_toggled(toggled, section, option) + action.toggled.connect(toggled) + action.setCheckable(True) + if section is not None and option is not None: + CONF.observe_configuration(action, section, option) + add_configuration_update(action) + + +def wrap_toggled(toggled, section, option): + """Wrap a toggle function to set a value on a configuration option.""" + if section is not None and option is not None: + @functools.wraps(toggled) + def wrapped_toggled(value): + CONF.set(section, option, value, recursive_notification=True) + toggled(value) + return wrapped_toggled + return toggled + + +def add_configuration_update(action): + """Add on_configuration_change to a SpyderAction that depends on CONF.""" + + def on_configuration_change(self, _option, _section, value): + self.blockSignals(True) + self.setChecked(value) + self.blockSignals(False) + method = types.MethodType(on_configuration_change, action) + setattr(action, 'on_configuration_change', method) + + +def add_shortcut_to_tooltip(action, context, name): + """Add the shortcut associated with a given action to its tooltip""" + if not hasattr(action, '_tooltip_backup'): + # We store the original tooltip of the action without its associated + # shortcut so that we can update the tooltip properly if shortcuts + # are changed by the user over the course of the current session. + # See spyder-ide/spyder#10726. + action._tooltip_backup = action.toolTip() + + try: + # Some shortcuts might not be assigned so we need to catch the error + shortcut = CONF.get_shortcut(context=context, name=name) + except (configparser.NoSectionError, configparser.NoOptionError): + shortcut = None + + if shortcut: + keyseq = QKeySequence(shortcut) + # See: spyder-ide/spyder#12168 + string = keyseq.toString(QKeySequence.NativeText) + action.setToolTip(u'{0} ({1})'.format(action._tooltip_backup, string)) + + +def add_actions(target, actions, insert_before=None): + """Add actions to a QMenu or a QToolBar.""" + previous_action = None + target_actions = list(target.actions()) + if target_actions: + previous_action = target_actions[-1] + if previous_action.isSeparator(): + previous_action = None + for action in actions: + if (action is None) and (previous_action is not None): + if insert_before is None: + target.addSeparator() + else: + target.insertSeparator(insert_before) + elif isinstance(action, QMenu): + if insert_before is None: + target.addMenu(action) + else: + target.insertMenu(insert_before, action) + elif isinstance(action, QAction): + if insert_before is None: + # This is needed in order to ignore adding an action whose + # wrapped C/C++ object has been deleted. + # See spyder-ide/spyder#5074. + try: + target.addAction(action) + except RuntimeError: + continue + else: + target.insertAction(insert_before, action) + previous_action = action + + +def get_item_user_text(item): + """Get QTreeWidgetItem user role string""" + return from_qvariant(item.data(0, Qt.UserRole), to_text_string) + + +def set_item_user_text(item, text): + """Set QTreeWidgetItem user role string""" + item.setData(0, Qt.UserRole, to_qvariant(text)) + + +def create_bookmark_action(parent, url, title, icon=None, shortcut=None): + """Create bookmark action""" + + @Slot() + def open_url(): + return start_file(url) + + return create_action( parent, title, shortcut=shortcut, icon=icon, + triggered=open_url) + + +def create_module_bookmark_actions(parent, bookmarks): + """ + Create bookmark actions depending on module installation: + bookmarks = ((module_name, url, title), ...) + """ + actions = [] + for key, url, title in bookmarks: + # Create actions for scientific distros only if Spyder is installed + # under them + create_act = True + if key == 'winpython': + if not programs.is_module_installed(key): + create_act = False + if create_act: + act = create_bookmark_action(parent, url, title) + actions.append(act) + return actions + + +def create_program_action(parent, text, name, icon=None, nt_name=None): + """Create action to run a program""" + if is_text_string(icon): + icon = ima.get_icon(icon) + if os.name == 'nt' and nt_name is not None: + name = nt_name + path = programs.find_program(name) + if path is not None: + return create_action(parent, text, icon=icon, + triggered=lambda: programs.run_program(name)) + + +def create_python_script_action(parent, text, icon, package, module, args=[]): + """Create action to run a GUI based Python script""" + if is_text_string(icon): + icon = ima.get_icon(icon) + if programs.python_script_exists(package, module): + return create_action(parent, text, icon=icon, + triggered=lambda: + programs.run_python_script(package, module, args)) + + +class DialogManager(QObject): + """ + Object that keep references to non-modal dialog boxes for another QObject, + typically a QMainWindow or any kind of QWidget + """ + + def __init__(self): + QObject.__init__(self) + self.dialogs = {} + + def show(self, dialog): + """Generic method to show a non-modal dialog and keep reference + to the Qt C++ object""" + for dlg in list(self.dialogs.values()): + if to_text_string(dlg.windowTitle()) \ + == to_text_string(dialog.windowTitle()): + dlg.show() + dlg.raise_() + break + else: + dialog.show() + self.dialogs[id(dialog)] = dialog + dialog.accepted.connect( + lambda eid=id(dialog): self.dialog_finished(eid)) + dialog.rejected.connect( + lambda eid=id(dialog): self.dialog_finished(eid)) + + def dialog_finished(self, dialog_id): + """Manage non-modal dialog boxes""" + return self.dialogs.pop(dialog_id) + + def close_all(self): + """Close all opened dialog boxes""" + for dlg in list(self.dialogs.values()): + dlg.reject() + + +def get_filetype_icon(fname): + """Return file type icon""" + ext = osp.splitext(fname)[1] + if ext.startswith('.'): + ext = ext[1:] + return ima.get_icon("%s.png" % ext, ima.icon('FileIcon')) + + +class SpyderAction(QAction): + """Spyder QAction class wrapper to handle cross platform patches.""" + + def __init__(self, *args, action_id=None, **kwargs): + """Spyder QAction class wrapper to handle cross platform patches.""" + super(SpyderAction, self).__init__(*args, **kwargs) + self.action_id = action_id + if sys.platform == "darwin": + self.setIconVisibleInMenu(False) + + def __str__(self): + return "SpyderAction('{0}')".format(self.text()) + + def __repr__(self): + return "SpyderAction('{0}')".format(self.text()) + + +class ShowStdIcons(QWidget): + """ + Dialog showing standard icons + """ + + def __init__(self, parent): + QWidget.__init__(self, parent) + layout = QHBoxLayout() + row_nb = 14 + cindex = 0 + for child in dir(QStyle): + if child.startswith('SP_'): + if cindex == 0: + col_layout = QVBoxLayout() + icon_layout = QHBoxLayout() + icon = ima.get_std_icon(child) + label = QLabel() + label.setPixmap(icon.pixmap(32, 32)) + icon_layout.addWidget(label) + icon_layout.addWidget(QLineEdit(child.replace('SP_', ''))) + col_layout.addLayout(icon_layout) + cindex = (cindex + 1) % row_nb + if cindex == 0: + layout.addLayout(col_layout) + self.setLayout(layout) + self.setWindowTitle('Standard Platform Icons') + self.setWindowIcon(ima.get_std_icon('TitleBarMenuButton')) + + +def show_std_icons(): + """ + Show all standard Icons + """ + app = qapplication() + dialog = ShowStdIcons(None) + dialog.show() + sys.exit(app.exec_()) + + +def calc_tools_spacing(tools_layout): + """ + Return a spacing (int) or None if we don't have the appropriate metrics + to calculate the spacing. + + We're trying to adapt the spacing below the tools_layout spacing so that + the main_widget has the same vertical position as the editor widgets + (which have tabs above). + + The required spacing is + + spacing = tabbar_height - tools_height + offset + + where the tabbar_heights were empirically determined for a combination of + operating systems and styles. Offsets were manually adjusted, so that the + heights of main_widgets and editor widgets match. This is probably + caused by a still not understood element of the layout and style metrics. + """ + metrics = { # (tabbar_height, offset) + 'nt.fusion': (32, 0), + 'nt.windowsvista': (21, 3), + 'nt.windowsxp': (24, 0), + 'nt.windows': (21, 3), + 'posix.breeze': (28, -1), + 'posix.oxygen': (38, -2), + 'posix.qtcurve': (27, 0), + 'posix.windows': (26, 0), + 'posix.fusion': (32, 0), + } + + style_name = qapplication().style().property('name') + key = '%s.%s' % (os.name, style_name) + + if key in metrics: + tabbar_height, offset = metrics[key] + tools_height = tools_layout.sizeHint().height() + spacing = tabbar_height - tools_height + offset + return max(spacing, 0) + + +def create_plugin_layout(tools_layout, main_widget=None): + """ + Returns a layout for a set of controls above a main widget. This is a + standard layout for many plugin panes (even though, it's currently + more often applied not to the pane itself but with in the one widget + contained in the pane. + + tools_layout: a layout containing the top toolbar + main_widget: the main widget. Can be None, if you want to add this + manually later on. + """ + layout = QVBoxLayout() + layout.setContentsMargins(0, 0, 0, 0) + spacing = calc_tools_spacing(tools_layout) + if spacing is not None: + layout.setSpacing(spacing) + + layout.addLayout(tools_layout) + if main_widget is not None: + layout.addWidget(main_widget) + return layout + + +def set_menu_icons(menu, state): + """Show/hide icons for menu actions.""" + menu_actions = menu.actions() + for action in menu_actions: + try: + if action.menu() is not None: + # This is submenu, so we need to call this again + set_menu_icons(action.menu(), state) + elif action.isSeparator(): + continue + else: + action.setIconVisibleInMenu(state) + except RuntimeError: + continue + + +class SpyderProxyStyle(QProxyStyle): + """Style proxy to adjust qdarkstyle issues.""" + + def styleHint(self, hint, option=0, widget=0, returnData=0): + """Override Qt method.""" + if hint == QStyle.SH_ComboBox_Popup: + # Disable combo-box popup top & bottom areas + # See: https://stackoverflow.com/a/21019371 + return 0 + + return QProxyStyle.styleHint(self, hint, option, widget, returnData) + + +class QInputDialogMultiline(QDialog): + """ + Build a replica interface of QInputDialog.getMultilineText. + + Based on: https://stackoverflow.com/a/58823967 + """ + + def __init__(self, parent, title, label, text='', **kwargs): + super(QInputDialogMultiline, self).__init__(parent, **kwargs) + if title is not None: + self.setWindowTitle(title) + + self.setLayout(QVBoxLayout()) + self.layout().addWidget(QLabel(label)) + self.text_edit = QPlainTextEdit() + self.layout().addWidget(self.text_edit) + + button_layout = QHBoxLayout() + button_layout.addStretch() + ok_button = QPushButton('OK') + button_layout.addWidget(ok_button) + cancel_button = QPushButton('Cancel') + button_layout.addWidget(cancel_button) + self.layout().addLayout(button_layout) + + self.text_edit.setPlainText(text) + ok_button.clicked.connect(self.accept) + cancel_button.clicked.connect(self.reject) + + +# ============================================================================= +# Only for macOS +# ============================================================================= +class MacApplication(QApplication): + """Subclass to be able to open external files with our Mac app""" + sig_open_external_file = Signal(str) + + def __init__(self, *args): + QApplication.__init__(self, *args) + self._never_shown = True + self._has_started = False + self._pending_file_open = [] + self._original_handlers = {} + + def event(self, event): + if event.type() == QEvent.FileOpen: + fname = str(event.file()) + if sys.argv and sys.argv[0] == fname: + # Ignore requests to open own script + # Later, mainwindow.initialize() will set sys.argv[0] to '' + pass + elif self._has_started: + self.sig_open_external_file.emit(fname) + else: + self._pending_file_open.append(fname) + return QApplication.event(self, event) + + +def restore_launchservices(): + """Restore LaunchServices to the previous state""" + app = QApplication.instance() + for key, handler in app._original_handlers.items(): + UTI, role = key + als.set_UTI_handler(UTI, role, handler) + + +def register_app_launchservices( + uniform_type_identifier="public.python-script", + role='editor'): + """ + Register app to the Apple launch services so it can open Python files + """ + app = QApplication.instance() + + old_handler = als.get_UTI_handler(uniform_type_identifier, role) + + app._original_handlers[(uniform_type_identifier, role)] = old_handler + + # Restore previous handle when quitting + app.aboutToQuit.connect(restore_launchservices) + + if not app._never_shown: + bundle_identifier = als.get_bundle_identifier() + als.set_UTI_handler( + uniform_type_identifier, role, bundle_identifier) + return + + # Wait to be visible to set ourselves as the UTI handler + def handle_applicationStateChanged(state): + if state == Qt.ApplicationActive and app._never_shown: + app._never_shown = False + bundle_identifier = als.get_bundle_identifier() + als.set_UTI_handler( + uniform_type_identifier, role, bundle_identifier) + + app.applicationStateChanged.connect(handle_applicationStateChanged) + + +if __name__ == "__main__": + show_std_icons() diff --git a/spyder/utils/sourcecode.py b/spyder/utils/sourcecode.py index f292fee5edd..4d955f3f990 100644 --- a/spyder/utils/sourcecode.py +++ b/spyder/utils/sourcecode.py @@ -1,240 +1,240 @@ -# -*- coding: utf-8 -*- -# -# Copyright © Spyder Project Contributors -# Licensed under the terms of the MIT License -# (see spyder/__init__.py for details) - -""" -Source code text utilities -""" - -# Standard library imports -import re -import os -import sys - -# Third-part imports -from pylsp._utils import get_eol_chars as _get_eol_chars - - -# Order is important: -EOL_CHARS = (("\r\n", 'nt'), ("\n", 'posix'), ("\r", 'mac')) - - -def get_eol_chars(text): - """ - Get text end-of-line (eol) characters. - - If no eol chars are found, return ones based on the operating - system. - - Parameters - ---------- - text: str - Text to get its eol chars from - - Returns - ------- - eol: str or None - Eol found in ``text``. - """ - eol_chars = _get_eol_chars(text) - - if not eol_chars: - if os.name == 'nt': - eol_chars = "\r\n" - elif sys.platform.startswith('linux'): - eol_chars = "\n" - elif sys.platform == 'darwin': - eol_chars = "\r" - else: - eol_chars = "\n" - - return eol_chars - - -def get_os_name_from_eol_chars(eol_chars): - """Return OS name from EOL characters""" - for chars, os_name in EOL_CHARS: - if eol_chars == chars: - return os_name - - -def get_eol_chars_from_os_name(os_name): - """Return EOL characters from OS name""" - for eol_chars, name in EOL_CHARS: - if name == os_name: - return eol_chars - - -def has_mixed_eol_chars(text): - """Detect if text has mixed EOL characters""" - eol_chars = get_eol_chars(text) - if eol_chars is None: - return False - correct_text = eol_chars.join((text+eol_chars).splitlines()) - return repr(correct_text) != repr(text) - - -def normalize_eols(text, eol='\n'): - """Use the same eol's in text""" - for eol_char, _ in EOL_CHARS: - if eol_char != eol: - text = text.replace(eol_char, eol) - return text - - -def fix_indentation(text, indent_chars): - """Replace tabs by spaces""" - return text.replace('\t', indent_chars) - - -def is_builtin(text): - """Test if passed string is the name of a Python builtin object""" - from spyder.py3compat import builtins - return text in [str(name) for name in dir(builtins) - if not name.startswith('_')] - - -def is_keyword(text): - """Test if passed string is the name of a Python keyword""" - import keyword - return text in keyword.kwlist - - -def get_primary_at(source_code, offset, retry=True): - """Return Python object in *source_code* at *offset* - Periods to the left of the cursor are carried forward - e.g. 'functools.par^tial' would yield 'functools.partial' - Retry prevents infinite recursion: retry only once - """ - obj = '' - left = re.split(r"[^0-9a-zA-Z_.]", source_code[:offset]) - if left and left[-1]: - obj = left[-1] - right = re.split(r"\W", source_code[offset:]) - if right and right[0]: - obj += right[0] - if obj and obj[0].isdigit(): - obj = '' - # account for opening chars with no text to the right - if not obj and retry and offset and source_code[offset - 1] in '([.': - return get_primary_at(source_code, offset - 1, retry=False) - return obj - - -def split_source(source_code): - '''Split source code into lines - ''' - eol_chars = get_eol_chars(source_code) - if eol_chars: - return source_code.split(eol_chars) - else: - return [source_code] - - -def get_identifiers(source_code): - '''Split source code into python identifier-like tokens''' - tokens = set(re.split(r"[^0-9a-zA-Z_.]", source_code)) - valid = re.compile(r'[a-zA-Z_]') - return [token for token in tokens if re.match(valid, token)] - -def path_components(path): - """ - Return the individual components of a given file path - string (for the local operating system). - - Taken from https://stackoverflow.com/q/21498939/438386 - """ - components = [] - # The loop guarantees that the returned components can be - # os.path.joined with the path separator and point to the same - # location: - while True: - (new_path, tail) = os.path.split(path) # Works on any platform - components.append(tail) - if new_path == path: # Root (including drive, on Windows) reached - break - path = new_path - components.append(new_path) - components.reverse() # First component first - return components - -def differentiate_prefix(path_components0, path_components1): - """ - Return the differentiated prefix of the given two iterables. - - Taken from https://stackoverflow.com/q/21498939/438386 - """ - longest_prefix = [] - root_comparison = False - common_elmt = None - for index, (elmt0, elmt1) in enumerate(zip(path_components0, path_components1)): - if elmt0 != elmt1: - if index == 2: - root_comparison = True - break - else: - common_elmt = elmt0 - longest_prefix.append(elmt0) - file_name_length = len(path_components0[len(path_components0) - 1]) - path_0 = os.path.join(*path_components0)[:-file_name_length - 1] - if len(longest_prefix) > 0: - longest_path_prefix = os.path.join(*longest_prefix) - longest_prefix_length = len(longest_path_prefix) + 1 - if path_0[longest_prefix_length:] != '' and not root_comparison: - path_0_components = path_components(path_0[longest_prefix_length:]) - if path_0_components[0] == ''and path_0_components[1] == ''and len( - path_0[longest_prefix_length:]) > 20: - path_0_components.insert(2, common_elmt) - path_0 = os.path.join(*path_0_components) - else: - path_0 = path_0[longest_prefix_length:] - elif not root_comparison: - path_0 = common_elmt - elif sys.platform.startswith('linux') and path_0 == '': - path_0 = '/' - return path_0 - -def disambiguate_fname(files_path_list, filename): - """Get tab title without ambiguation.""" - fname = os.path.basename(filename) - same_name_files = get_same_name_files(files_path_list, fname) - if len(same_name_files) > 1: - compare_path = shortest_path(same_name_files) - if compare_path == filename: - same_name_files.remove(path_components(filename)) - compare_path = shortest_path(same_name_files) - diff_path = differentiate_prefix(path_components(filename), - path_components(compare_path)) - diff_path_length = len(diff_path) - path_component = path_components(diff_path) - if (diff_path_length > 20 and len(path_component) > 2): - if path_component[0] != '/' and path_component[0] != '': - path_component = [path_component[0], '...', - path_component[-1]] - else: - path_component = [path_component[2], '...', - path_component[-1]] - diff_path = os.path.join(*path_component) - fname = fname + " - " + diff_path - return fname - -def get_same_name_files(files_path_list, filename): - """Get a list of the path components of the files with the same name.""" - same_name_files = [] - for fname in files_path_list: - if filename == os.path.basename(fname): - same_name_files.append(path_components(fname)) - return same_name_files - -def shortest_path(files_path_list): - """Shortest path between files in the list.""" - if len(files_path_list) > 0: - shortest_path = files_path_list[0] - shortest_path_length = len(files_path_list[0]) - for path_elmts in files_path_list: - if len(path_elmts) < shortest_path_length: - shortest_path_length = len(path_elmts) - shortest_path = path_elmts - return os.path.join(*shortest_path) +# -*- coding: utf-8 -*- +# +# Copyright © Spyder Project Contributors +# Licensed under the terms of the MIT License +# (see spyder/__init__.py for details) + +""" +Source code text utilities +""" + +# Standard library imports +import re +import os +import sys + +# Third-part imports +from pylsp._utils import get_eol_chars as _get_eol_chars + + +# Order is important: +EOL_CHARS = (("\r\n", 'nt'), ("\n", 'posix'), ("\r", 'mac')) + + +def get_eol_chars(text): + """ + Get text end-of-line (eol) characters. + + If no eol chars are found, return ones based on the operating + system. + + Parameters + ---------- + text: str + Text to get its eol chars from + + Returns + ------- + eol: str or None + Eol found in ``text``. + """ + eol_chars = _get_eol_chars(text) + + if not eol_chars: + if os.name == 'nt': + eol_chars = "\r\n" + elif sys.platform.startswith('linux'): + eol_chars = "\n" + elif sys.platform == 'darwin': + eol_chars = "\r" + else: + eol_chars = "\n" + + return eol_chars + + +def get_os_name_from_eol_chars(eol_chars): + """Return OS name from EOL characters""" + for chars, os_name in EOL_CHARS: + if eol_chars == chars: + return os_name + + +def get_eol_chars_from_os_name(os_name): + """Return EOL characters from OS name""" + for eol_chars, name in EOL_CHARS: + if name == os_name: + return eol_chars + + +def has_mixed_eol_chars(text): + """Detect if text has mixed EOL characters""" + eol_chars = get_eol_chars(text) + if eol_chars is None: + return False + correct_text = eol_chars.join((text+eol_chars).splitlines()) + return repr(correct_text) != repr(text) + + +def normalize_eols(text, eol='\n'): + """Use the same eol's in text""" + for eol_char, _ in EOL_CHARS: + if eol_char != eol: + text = text.replace(eol_char, eol) + return text + + +def fix_indentation(text, indent_chars): + """Replace tabs by spaces""" + return text.replace('\t', indent_chars) + + +def is_builtin(text): + """Test if passed string is the name of a Python builtin object""" + from spyder.py3compat import builtins + return text in [str(name) for name in dir(builtins) + if not name.startswith('_')] + + +def is_keyword(text): + """Test if passed string is the name of a Python keyword""" + import keyword + return text in keyword.kwlist + + +def get_primary_at(source_code, offset, retry=True): + """Return Python object in *source_code* at *offset* + Periods to the left of the cursor are carried forward + e.g. 'functools.par^tial' would yield 'functools.partial' + Retry prevents infinite recursion: retry only once + """ + obj = '' + left = re.split(r"[^0-9a-zA-Z_.]", source_code[:offset]) + if left and left[-1]: + obj = left[-1] + right = re.split(r"\W", source_code[offset:]) + if right and right[0]: + obj += right[0] + if obj and obj[0].isdigit(): + obj = '' + # account for opening chars with no text to the right + if not obj and retry and offset and source_code[offset - 1] in '([.': + return get_primary_at(source_code, offset - 1, retry=False) + return obj + + +def split_source(source_code): + '''Split source code into lines + ''' + eol_chars = get_eol_chars(source_code) + if eol_chars: + return source_code.split(eol_chars) + else: + return [source_code] + + +def get_identifiers(source_code): + '''Split source code into python identifier-like tokens''' + tokens = set(re.split(r"[^0-9a-zA-Z_.]", source_code)) + valid = re.compile(r'[a-zA-Z_]') + return [token for token in tokens if re.match(valid, token)] + +def path_components(path): + """ + Return the individual components of a given file path + string (for the local operating system). + + Taken from https://stackoverflow.com/q/21498939/438386 + """ + components = [] + # The loop guarantees that the returned components can be + # os.path.joined with the path separator and point to the same + # location: + while True: + (new_path, tail) = os.path.split(path) # Works on any platform + components.append(tail) + if new_path == path: # Root (including drive, on Windows) reached + break + path = new_path + components.append(new_path) + components.reverse() # First component first + return components + +def differentiate_prefix(path_components0, path_components1): + """ + Return the differentiated prefix of the given two iterables. + + Taken from https://stackoverflow.com/q/21498939/438386 + """ + longest_prefix = [] + root_comparison = False + common_elmt = None + for index, (elmt0, elmt1) in enumerate(zip(path_components0, path_components1)): + if elmt0 != elmt1: + if index == 2: + root_comparison = True + break + else: + common_elmt = elmt0 + longest_prefix.append(elmt0) + file_name_length = len(path_components0[len(path_components0) - 1]) + path_0 = os.path.join(*path_components0)[:-file_name_length - 1] + if len(longest_prefix) > 0: + longest_path_prefix = os.path.join(*longest_prefix) + longest_prefix_length = len(longest_path_prefix) + 1 + if path_0[longest_prefix_length:] != '' and not root_comparison: + path_0_components = path_components(path_0[longest_prefix_length:]) + if path_0_components[0] == ''and path_0_components[1] == ''and len( + path_0[longest_prefix_length:]) > 20: + path_0_components.insert(2, common_elmt) + path_0 = os.path.join(*path_0_components) + else: + path_0 = path_0[longest_prefix_length:] + elif not root_comparison: + path_0 = common_elmt + elif sys.platform.startswith('linux') and path_0 == '': + path_0 = '/' + return path_0 + +def disambiguate_fname(files_path_list, filename): + """Get tab title without ambiguation.""" + fname = os.path.basename(filename) + same_name_files = get_same_name_files(files_path_list, fname) + if len(same_name_files) > 1: + compare_path = shortest_path(same_name_files) + if compare_path == filename: + same_name_files.remove(path_components(filename)) + compare_path = shortest_path(same_name_files) + diff_path = differentiate_prefix(path_components(filename), + path_components(compare_path)) + diff_path_length = len(diff_path) + path_component = path_components(diff_path) + if (diff_path_length > 20 and len(path_component) > 2): + if path_component[0] != '/' and path_component[0] != '': + path_component = [path_component[0], '...', + path_component[-1]] + else: + path_component = [path_component[2], '...', + path_component[-1]] + diff_path = os.path.join(*path_component) + fname = fname + " - " + diff_path + return fname + +def get_same_name_files(files_path_list, filename): + """Get a list of the path components of the files with the same name.""" + same_name_files = [] + for fname in files_path_list: + if filename == os.path.basename(fname): + same_name_files.append(path_components(fname)) + return same_name_files + +def shortest_path(files_path_list): + """Shortest path between files in the list.""" + if len(files_path_list) > 0: + shortest_path = files_path_list[0] + shortest_path_length = len(files_path_list[0]) + for path_elmts in files_path_list: + if len(path_elmts) < shortest_path_length: + shortest_path_length = len(path_elmts) + shortest_path = path_elmts + return os.path.join(*shortest_path) diff --git a/spyder/utils/syntaxhighlighters.py b/spyder/utils/syntaxhighlighters.py index 89b3e1b5098..40d2999eac0 100644 --- a/spyder/utils/syntaxhighlighters.py +++ b/spyder/utils/syntaxhighlighters.py @@ -1,1438 +1,1438 @@ -# -*- coding: utf-8 -*- -# -# Copyright © Spyder Project Contributors -# Licensed under the terms of the MIT License -# (see spyder/__init__.py for details) - -""" -Editor widget syntax highlighters based on QtGui.QSyntaxHighlighter -(Python syntax highlighting rules are inspired from idlelib) -""" - -# Standard library imports -from __future__ import print_function -import keyword -import os -import re -import weakref - -# Third party imports -from pygments.lexer import RegexLexer, bygroups -from pygments.lexers import get_lexer_by_name -from pygments.token import (Text, Other, Keyword, Name, String, Number, - Comment, Generic, Token) -from qtpy.QtCore import Qt, QTimer, Signal -from qtpy.QtGui import (QColor, QCursor, QFont, QSyntaxHighlighter, - QTextCharFormat, QTextOption) -from qtpy.QtWidgets import QApplication - -# Local imports -from spyder.config.base import _ -from spyder.config.manager import CONF -from spyder.py3compat import (builtins, is_text_string, to_text_string, PY3, - PY36_OR_MORE) -from spyder.plugins.editor.utils.languages import CELL_LANGUAGES -from spyder.plugins.editor.utils.editor import TextBlockHelper as tbh -from spyder.plugins.editor.utils.editor import BlockUserData -from spyder.utils.workers import WorkerManager -from spyder.plugins.outlineexplorer.api import OutlineExplorerData -from spyder.utils.qstringhelpers import qstring_length - - - -# ============================================================================= -# Constants -# ============================================================================= -DEFAULT_PATTERNS = { - 'file': - r'file:///?(?:[\S]*)', - 'issue': - (r'(?:(?:(?:gh:)|(?:gl:)|(?:bb:))?[\w\-_]*/[\w\-_]*#\d+)|' - r'(?:(?:(?:gh-)|(?:gl-)|(?:bb-))\d+)'), - 'mail': - r'(?:mailto:\s*)?([a-z0-9_\.-]+)@([\da-z\.-]+)\.([a-z\.]{2,6})', - 'url': - r"https?://([\da-z\.-]+)\.([a-z\.]{2,6})([/\w\.-]*)[^ ^'^\"]+", -} - -COLOR_SCHEME_KEYS = { - "background": _("Background:"), - "currentline": _("Current line:"), - "currentcell": _("Current cell:"), - "occurrence": _("Occurrence:"), - "ctrlclick": _("Link:"), - "sideareas": _("Side areas:"), - "matched_p": _("Matched
    parens:"), - "unmatched_p": _("Unmatched
    parens:"), - "normal": _("Normal text:"), - "keyword": _("Keyword:"), - "builtin": _("Builtin:"), - "definition": _("Definition:"), - "comment": _("Comment:"), - "string": _("String:"), - "number": _("Number:"), - "instance": _("Instance:"), - "magic": _("Magic:"), -} - -COLOR_SCHEME_DEFAULT_VALUES = { - "background": "#19232D", - "currentline": "#3a424a", - "currentcell": "#292d3e", - "occurrence": "#1A72BB", - "ctrlclick": "#179ae0", - "sideareas": "#222b35", - "matched_p": "#0bbe0b", - "unmatched_p": "#ff4340", - "normal": ("#ffffff", False, False), - "keyword": ("#c670e0", False, False), - "builtin": ("#fab16c", False, False), - "definition": ("#57d6e4", True, False), - "comment": ("#999999", False, False), - "string": ("#b0e686", False, True), - "number": ("#faed5c", False, False), - "instance": ("#ee6772", False, True), - "magic": ("#c670e0", False, False), -} - -COLOR_SCHEME_NAMES = CONF.get('appearance', 'names') - -# Mapping for file extensions that use Pygments highlighting but should use -# different lexers than Pygments' autodetection suggests. Keys are file -# extensions or tuples of extensions, values are Pygments lexer names. -CUSTOM_EXTENSION_LEXER = { - '.ipynb': 'json', - '.nt': 'bat', - '.m': 'matlab', - ('.properties', '.session', '.inf', '.reg', '.url', - '.cfg', '.cnf', '.aut', '.iss'): 'ini' -} - -# Convert custom extensions into a one-to-one mapping for easier lookup. -custom_extension_lexer_mapping = {} -for key, value in CUSTOM_EXTENSION_LEXER.items(): - # Single key is mapped unchanged. - if is_text_string(key): - custom_extension_lexer_mapping[key] = value - # Tuple of keys is iterated over and each is mapped to value. - else: - for k in key: - custom_extension_lexer_mapping[k] = value - - -#============================================================================== -# Auxiliary functions -#============================================================================== -def get_span(match, key=None): - if key is not None: - start, end = match.span(key) - else: - start, end = match.span() - start16 = qstring_length(match.string[:start]) - end16 = start16 + qstring_length(match.string[start:end]) - return start16, end16 - - -def get_color_scheme(name): - """Get a color scheme from config using its name""" - name = name.lower() - scheme = {} - for key in COLOR_SCHEME_KEYS: - try: - scheme[key] = CONF.get('appearance', name+'/'+key) - except: - scheme[key] = CONF.get('appearance', 'spyder/'+key) - return scheme - - -def any(name, alternates): - "Return a named group pattern matching list of alternates." - return "(?P<%s>" % name + "|".join(alternates) + ")" - - -def create_patterns(patterns, compile=False): - """ - Create patterns from pattern dictionary. - - The key correspond to the group name and the values a list of - possible pattern alternatives. - """ - all_patterns = [] - for key, value in patterns.items(): - all_patterns.append(any(key, [value])) - - regex = '|'.join(all_patterns) - - if compile: - regex = re.compile(regex) - - return regex - - -DEFAULT_PATTERNS_TEXT = create_patterns(DEFAULT_PATTERNS, compile=False) -DEFAULT_COMPILED_PATTERNS = re.compile(create_patterns(DEFAULT_PATTERNS, - compile=True)) - - -#============================================================================== -# Syntax highlighting color schemes -#============================================================================== -class BaseSH(QSyntaxHighlighter): - """Base Syntax Highlighter Class""" - # Syntax highlighting rules: - PROG = None - BLANKPROG = re.compile(r"\s+") - # Syntax highlighting states (from one text block to another): - NORMAL = 0 - # Syntax highlighting parameters. - BLANK_ALPHA_FACTOR = 0.31 - - sig_outline_explorer_data_changed = Signal() - - # Use to signal font change - sig_font_changed = Signal() - - def __init__(self, parent, font=None, color_scheme='Spyder'): - QSyntaxHighlighter.__init__(self, parent) - - self.font = font - if is_text_string(color_scheme): - self.color_scheme = get_color_scheme(color_scheme) - else: - self.color_scheme = color_scheme - - self.background_color = None - self.currentline_color = None - self.currentcell_color = None - self.occurrence_color = None - self.ctrlclick_color = None - self.sideareas_color = None - self.matched_p_color = None - self.unmatched_p_color = None - - self.formats = None - self.setup_formats(font) - - self.cell_separators = None - self.editor = None - self.patterns = DEFAULT_COMPILED_PATTERNS - - # List of cells - self._cell_list = [] - - def get_background_color(self): - return QColor(self.background_color) - - def get_foreground_color(self): - """Return foreground ('normal' text) color""" - return self.formats["normal"].foreground().color() - - def get_currentline_color(self): - return QColor(self.currentline_color) - - def get_currentcell_color(self): - return QColor(self.currentcell_color) - - def get_occurrence_color(self): - return QColor(self.occurrence_color) - - def get_ctrlclick_color(self): - return QColor(self.ctrlclick_color) - - def get_sideareas_color(self): - return QColor(self.sideareas_color) - - def get_matched_p_color(self): - return QColor(self.matched_p_color) - - def get_unmatched_p_color(self): - return QColor(self.unmatched_p_color) - - def get_comment_color(self): - """ Return color for the comments """ - return self.formats['comment'].foreground().color() - - def get_color_name(self, fmt): - """Return color name assigned to a given format""" - return self.formats[fmt].foreground().color().name() - - def setup_formats(self, font=None): - base_format = QTextCharFormat() - if font is not None: - self.font = font - if self.font is not None: - base_format.setFont(self.font) - self.sig_font_changed.emit() - self.formats = {} - colors = self.color_scheme.copy() - self.background_color = colors.pop("background") - self.currentline_color = colors.pop("currentline") - self.currentcell_color = colors.pop("currentcell") - self.occurrence_color = colors.pop("occurrence") - self.ctrlclick_color = colors.pop("ctrlclick") - self.sideareas_color = colors.pop("sideareas") - self.matched_p_color = colors.pop("matched_p") - self.unmatched_p_color = colors.pop("unmatched_p") - for name, (color, bold, italic) in list(colors.items()): - format = QTextCharFormat(base_format) - format.setForeground(QColor(color)) - if bold: - format.setFontWeight(QFont.Bold) - format.setFontItalic(italic) - self.formats[name] = format - - def set_color_scheme(self, color_scheme): - if is_text_string(color_scheme): - self.color_scheme = get_color_scheme(color_scheme) - else: - self.color_scheme = color_scheme - - self.setup_formats() - self.rehighlight() - - @staticmethod - def _find_prev_non_blank_block(current_block): - previous_block = (current_block.previous() - if current_block.blockNumber() else None) - # find the previous non-blank block - while (previous_block and previous_block.blockNumber() and - previous_block.text().strip() == ''): - previous_block = previous_block.previous() - return previous_block - - def update_patterns(self, patterns): - """Update patterns to underline.""" - all_patterns = DEFAULT_PATTERNS.copy() - additional_patterns = patterns.copy() - - # Check that default keys are not overwritten - for key in DEFAULT_PATTERNS.keys(): - if key in additional_patterns: - # TODO: print warning or check this at the plugin level? - additional_patterns.pop(key) - all_patterns.update(additional_patterns) - - self.patterns = create_patterns(all_patterns, compile=True) - - def highlightBlock(self, text): - """ - Highlights a block of text. Please do not override, this method. - Instead you should implement - :func:`spyder.utils.syntaxhighplighters.SyntaxHighlighter.highlight_block`. - - :param text: text to highlight. - """ - self.highlight_block(text) - - def highlight_block(self, text): - """ - Abstract method. Override this to apply syntax highlighting. - - :param text: Line of text to highlight. - :param block: current block - """ - raise NotImplementedError() - - def highlight_patterns(self, text, offset=0): - """Highlight URI and mailto: patterns.""" - for match in self.patterns.finditer(text, offset): - for __, value in list(match.groupdict().items()): - if value: - start, end = get_span(match) - start = max([0, start + offset]) - end = max([0, end + offset]) - font = self.format(start) - font.setUnderlineStyle(QTextCharFormat.SingleUnderline) - self.setFormat(start, end - start, font) - - def highlight_spaces(self, text, offset=0): - """ - Make blank space less apparent by setting the foreground alpha. - This only has an effect when 'Show blank space' is turned on. - """ - flags_text = self.document().defaultTextOption().flags() - show_blanks = flags_text & QTextOption.ShowTabsAndSpaces - if show_blanks: - format_leading = self.formats.get("leading", None) - format_trailing = self.formats.get("trailing", None) - text = text[offset:] - for match in self.BLANKPROG.finditer(text): - start, end = get_span(match) - start = max([0, start+offset]) - end = max([0, end+offset]) - # Format trailing spaces at the end of the line. - if end == qstring_length(text) and format_trailing is not None: - self.setFormat(start, end - start, format_trailing) - # Format leading spaces, e.g. indentation. - if start == 0 and format_leading is not None: - self.setFormat(start, end - start, format_leading) - format = self.format(start) - color_foreground = format.foreground().color() - alpha_new = self.BLANK_ALPHA_FACTOR * color_foreground.alphaF() - color_foreground.setAlphaF(alpha_new) - self.setFormat(start, end - start, color_foreground) - - def highlight_extras(self, text, offset=0): - """ - Perform additional global text highlight. - - Derived classes could call this function at the end of - highlight_block(). - """ - self.highlight_spaces(text, offset=offset) - self.highlight_patterns(text, offset=offset) - - def rehighlight(self): - QApplication.setOverrideCursor(QCursor(Qt.WaitCursor)) - QSyntaxHighlighter.rehighlight(self) - QApplication.restoreOverrideCursor() - - -class TextSH(BaseSH): - """Simple Text Syntax Highlighter Class (only highlight spaces).""" - - def highlight_block(self, text): - """Implement highlight, only highlight spaces.""" - text = to_text_string(text) - self.setFormat(0, qstring_length(text), self.formats["normal"]) - self.highlight_extras(text) - - -class GenericSH(BaseSH): - """Generic Syntax Highlighter""" - # Syntax highlighting rules: - PROG = None # to be redefined in child classes - - def highlight_block(self, text): - """Implement highlight using regex defined in children classes.""" - text = to_text_string(text) - self.setFormat(0, qstring_length(text), self.formats["normal"]) - - index = 0 - for match in self.PROG.finditer(text): - for key, value in list(match.groupdict().items()): - if value: - start, end = get_span(match, key) - index += end-start - self.setFormat(start, end-start, self.formats[key]) - - self.highlight_extras(text) - - -#============================================================================== -# Python syntax highlighter -#============================================================================== -def make_python_patterns(additional_keywords=[], additional_builtins=[]): - "Strongly inspired from idlelib.ColorDelegator.make_pat" - kwlist = keyword.kwlist + additional_keywords - builtinlist = [str(name) for name in dir(builtins) - if not name.startswith('_')] + additional_builtins - repeated = set(kwlist) & set(builtinlist) - for repeated_element in repeated: - kwlist.remove(repeated_element) - kw = r"\b" + any("keyword", kwlist) + r"\b" - builtin = r"([^.'\"\\#]\b|^)" + any("builtin", builtinlist) + r"\b" - comment = any("comment", [r"#[^\n]*"]) - instance = any("instance", [r"\bself\b", - r"\bcls\b", - (r"^\s*@([a-zA-Z_][a-zA-Z0-9_]*)" - r"(\.[a-zA-Z_][a-zA-Z0-9_]*)*")]) - number_regex = [r"\b[+-]?[0-9]+[lLjJ]?\b", - r"\b[+-]?0[xX][0-9A-Fa-f]+[lL]?\b", - r"\b[+-]?0[oO][0-7]+[lL]?\b", - r"\b[+-]?0[bB][01]+[lL]?\b", - r"\b[+-]?[0-9]+(?:\.[0-9]+)?(?:[eE][+-]?[0-9]+)?[jJ]?\b"] - if PY3: - prefix = "r|u|R|U|f|F|fr|Fr|fR|FR|rf|rF|Rf|RF|b|B|br|Br|bR|BR|rb|rB|Rb|RB" - else: - prefix = "r|u|ur|R|U|UR|Ur|uR|b|B|br|Br|bR|BR" - sqstring = r"(\b(%s))?'[^'\\\n]*(\\.[^'\\\n]*)*'?" % prefix - dqstring = r'(\b(%s))?"[^"\\\n]*(\\.[^"\\\n]*)*"?' % prefix - uf_sqstring = r"(\b(%s))?'[^'\\\n]*(\\.[^'\\\n]*)*(\\)$(?!')$" % prefix - uf_dqstring = r'(\b(%s))?"[^"\\\n]*(\\.[^"\\\n]*)*(\\)$(?!")$' % prefix - ufe_sqstring = r"(\b(%s))?'[^'\\\n]*(\\.[^'\\\n]*)*(?!\\)$(?!')$" % prefix - ufe_dqstring = r'(\b(%s))?"[^"\\\n]*(\\.[^"\\\n]*)*(?!\\)$(?!")$' % prefix - sq3string = r"(\b(%s))?'''[^'\\]*((\\.|'(?!''))[^'\\]*)*(''')?" % prefix - dq3string = r'(\b(%s))?"""[^"\\]*((\\.|"(?!""))[^"\\]*)*(""")?' % prefix - uf_sq3string = r"(\b(%s))?'''[^'\\]*((\\.|'(?!''))[^'\\]*)*(\\)?(?!''')$" \ - % prefix - uf_dq3string = r'(\b(%s))?"""[^"\\]*((\\.|"(?!""))[^"\\]*)*(\\)?(?!""")$' \ - % prefix - # Needed to achieve correct highlighting in Python 3.6+ - # See spyder-ide/spyder#7324. - if PY36_OR_MORE: - # Based on - # https://github.com/python/cpython/blob/ - # 81950495ba2c36056e0ce48fd37d514816c26747/Lib/tokenize.py#L117 - # In order: Hexnumber, Binnumber, Octnumber, Decnumber, - # Pointfloat + Exponent, Expfloat, Imagnumber - number_regex = [ - r"\b[+-]?0[xX](?:_?[0-9A-Fa-f])+[lL]?\b", - r"\b[+-]?0[bB](?:_?[01])+[lL]?\b", - r"\b[+-]?0[oO](?:_?[0-7])+[lL]?\b", - r"\b[+-]?(?:0(?:_?0)*|[1-9](?:_?[0-9])*)[lL]?\b", - r"\b((\.[0-9](?:_?[0-9])*')|\.[0-9](?:_?[0-9])*)" - "([eE][+-]?[0-9](?:_?[0-9])*)?[jJ]?\b", - r"\b[0-9](?:_?[0-9])*([eE][+-]?[0-9](?:_?[0-9])*)?[jJ]?\b", - r"\b[0-9](?:_?[0-9])*[jJ]\b"] - number = any("number", number_regex) - - string = any("string", [sq3string, dq3string, sqstring, dqstring]) - ufstring1 = any("uf_sqstring", [uf_sqstring]) - ufstring2 = any("uf_dqstring", [uf_dqstring]) - ufstring3 = any("uf_sq3string", [uf_sq3string]) - ufstring4 = any("uf_dq3string", [uf_dq3string]) - ufstring5 = any("ufe_sqstring", [ufe_sqstring]) - ufstring6 = any("ufe_dqstring", [ufe_dqstring]) - return "|".join([instance, kw, builtin, comment, - ufstring1, ufstring2, ufstring3, ufstring4, ufstring5, - ufstring6, string, number, any("SYNC", [r"\n"])]) - - -def make_ipython_patterns(additional_keywords=[], additional_builtins=[]): - return (make_python_patterns(additional_keywords, additional_builtins) - + r"|^\s*%%?(?P[^\s]*)") - - -def get_code_cell_name(text): - """Returns a code cell name from a code cell comment.""" - name = text.strip().lstrip("#% ") - if name.startswith(""): - name = name[10:].lstrip() - elif name.startswith("In["): - name = name[2:] - if name.endswith("]:"): - name = name[:-1] - name = name.strip() - return name - - -class PythonSH(BaseSH): - """Python Syntax Highlighter""" - # Syntax highlighting rules: - add_kw = ['async', 'await'] - PROG = re.compile(make_python_patterns(additional_keywords=add_kw), re.S) - IDPROG = re.compile(r"\s+(\w+)", re.S) - ASPROG = re.compile(r"\b(as)\b") - # Syntax highlighting states (from one text block to another): - (NORMAL, INSIDE_SQ3STRING, INSIDE_DQ3STRING, - INSIDE_SQSTRING, INSIDE_DQSTRING, - INSIDE_NON_MULTILINE_STRING) = list(range(6)) - DEF_TYPES = {"def": OutlineExplorerData.FUNCTION, - "class": OutlineExplorerData.CLASS} - # Comments suitable for Outline Explorer - OECOMMENT = re.compile(r'^(# ?--[-]+|##[#]+ )[ -]*[^- ]+') - - def __init__(self, parent, font=None, color_scheme='Spyder'): - BaseSH.__init__(self, parent, font, color_scheme) - self.cell_separators = CELL_LANGUAGES['Python'] - # Avoid updating the outline explorer with every single letter typed - self.outline_explorer_data_update_timer = QTimer() - self.outline_explorer_data_update_timer.setSingleShot(True) - - def highlight_match(self, text, match, key, value, offset, - state, import_stmt, oedata): - """Highlight a single match.""" - start, end = get_span(match, key) - start = max([0, start+offset]) - end = max([0, end+offset]) - length = end - start - if key == "uf_sq3string": - self.setFormat(start, length, self.formats["string"]) - state = self.INSIDE_SQ3STRING - elif key == "uf_dq3string": - self.setFormat(start, length, self.formats["string"]) - state = self.INSIDE_DQ3STRING - elif key == "uf_sqstring": - self.setFormat(start, length, self.formats["string"]) - state = self.INSIDE_SQSTRING - elif key == "uf_dqstring": - self.setFormat(start, length, self.formats["string"]) - state = self.INSIDE_DQSTRING - elif key in ["ufe_sqstring", "ufe_dqstring"]: - self.setFormat(start, length, self.formats["string"]) - state = self.INSIDE_NON_MULTILINE_STRING - else: - self.setFormat(start, length, self.formats[key]) - if key == "comment": - if text.lstrip().startswith(self.cell_separators): - oedata = OutlineExplorerData(self.currentBlock()) - oedata.text = to_text_string(text).strip() - # cell_head: string containing the first group - # of '%'s in the cell header - cell_head = re.search(r"%+|$", text.lstrip()).group() - if cell_head == '': - oedata.cell_level = 0 - else: - oedata.cell_level = qstring_length(cell_head) - 2 - oedata.fold_level = start - oedata.def_type = OutlineExplorerData.CELL - def_name = get_code_cell_name(text) - oedata.def_name = def_name - # Keep list of cells for performence reasons - self._cell_list.append(oedata) - elif self.OECOMMENT.match(text.lstrip()): - oedata = OutlineExplorerData(self.currentBlock()) - oedata.text = to_text_string(text).strip() - oedata.fold_level = start - oedata.def_type = OutlineExplorerData.COMMENT - oedata.def_name = text.strip() - elif key == "keyword": - if value in ("def", "class"): - match1 = self.IDPROG.match(text, end) - if match1: - start1, end1 = get_span(match1, 1) - self.setFormat(start1, end1-start1, - self.formats["definition"]) - oedata = OutlineExplorerData(self.currentBlock()) - oedata.text = to_text_string(text) - oedata.fold_level = (qstring_length(text) - - qstring_length(text.lstrip())) - oedata.def_type = self.DEF_TYPES[to_text_string(value)] - oedata.def_name = text[start1:end1] - oedata.color = self.formats["definition"] - elif value in ("elif", "else", "except", "finally", - "for", "if", "try", "while", - "with"): - if text.lstrip().startswith(value): - oedata = OutlineExplorerData(self.currentBlock()) - oedata.text = to_text_string(text).strip() - oedata.fold_level = start - oedata.def_type = OutlineExplorerData.STATEMENT - oedata.def_name = text.strip() - elif value == "import": - import_stmt = text.strip() - # color all the "as" words on same line, except - # if in a comment; cheap approximation to the - # truth - if '#' in text: - endpos = qstring_length(text[:text.index('#')]) - else: - endpos = qstring_length(text) - while True: - match1 = self.ASPROG.match(text, end, endpos) - if not match1: - break - start, end = get_span(match1, 1) - self.setFormat(start, length, self.formats["keyword"]) - - return state, import_stmt, oedata - - def highlight_block(self, text): - """Implement specific highlight for Python.""" - text = to_text_string(text) - prev_state = tbh.get_state(self.currentBlock().previous()) - if prev_state == self.INSIDE_DQ3STRING: - offset = -4 - text = r'""" '+text - elif prev_state == self.INSIDE_SQ3STRING: - offset = -4 - text = r"''' "+text - elif prev_state == self.INSIDE_DQSTRING: - offset = -2 - text = r'" '+text - elif prev_state == self.INSIDE_SQSTRING: - offset = -2 - text = r"' "+text - else: - offset = 0 - prev_state = self.NORMAL - - oedata = None - import_stmt = None - - self.setFormat(0, qstring_length(text), self.formats["normal"]) - - state = self.NORMAL - for match in self.PROG.finditer(text): - for key, value in list(match.groupdict().items()): - if value: - state, import_stmt, oedata = self.highlight_match( - text, match, key, value, offset, - state, import_stmt, oedata) - - tbh.set_state(self.currentBlock(), state) - - # Use normal format for indentation and trailing spaces - # Unless we are in a string - states_multiline_string = [ - self.INSIDE_DQ3STRING, self.INSIDE_SQ3STRING, - self.INSIDE_DQSTRING, self.INSIDE_SQSTRING] - states_string = states_multiline_string + [ - self.INSIDE_NON_MULTILINE_STRING] - self.formats['leading'] = self.formats['normal'] - if prev_state in states_multiline_string: - self.formats['leading'] = self.formats["string"] - self.formats['trailing'] = self.formats['normal'] - if state in states_string: - self.formats['trailing'] = self.formats['string'] - self.highlight_extras(text, offset) - - block = self.currentBlock() - data = block.userData() - - need_data = (oedata or import_stmt) - - if need_data and not data: - data = BlockUserData(self.editor) - - # Try updating - update = False - if oedata and data and data.oedata: - update = data.oedata.update(oedata) - - if data and not update: - data.oedata = oedata - self.outline_explorer_data_update_timer.start(500) - - if (import_stmt) or (data and data.import_statement): - data.import_statement = import_stmt - - block.setUserData(data) - - def get_import_statements(self): - """Get import statment list.""" - block = self.document().firstBlock() - statments = [] - while block.isValid(): - data = block.userData() - if data and data.import_statement: - statments.append(data.import_statement) - block = block.next() - return statments - - def rehighlight(self): - BaseSH.rehighlight(self) - - -# ============================================================================= -# IPython syntax highlighter -# ============================================================================= -class IPythonSH(PythonSH): - """IPython Syntax Highlighter""" - add_kw = ['async', 'await'] - PROG = re.compile(make_ipython_patterns(additional_keywords=add_kw), re.S) - - -#============================================================================== -# Cython syntax highlighter -#============================================================================== -C_TYPES = 'bool char double enum float int long mutable short signed struct unsigned void NULL' - -class CythonSH(PythonSH): - """Cython Syntax Highlighter""" - ADDITIONAL_KEYWORDS = [ - "cdef", "ctypedef", "cpdef", "inline", "cimport", "extern", - "include", "begin", "end", "by", "gil", "nogil", "const", "public", - "readonly", "fused", "static", "api", "DEF", "IF", "ELIF", "ELSE"] - - ADDITIONAL_BUILTINS = C_TYPES.split() + [ - "array", "bint", "Py_ssize_t", "intern", "reload", "sizeof", "NULL"] - PROG = re.compile(make_python_patterns(ADDITIONAL_KEYWORDS, - ADDITIONAL_BUILTINS), re.S) - IDPROG = re.compile(r"\s+([\w\.]+)", re.S) - - -#============================================================================== -# Enaml syntax highlighter -#============================================================================== -class EnamlSH(PythonSH): - """Enaml Syntax Highlighter""" - ADDITIONAL_KEYWORDS = ["enamldef", "template", "attr", "event", "const", "alias", - "func"] - ADDITIONAL_BUILTINS = [] - PROG = re.compile(make_python_patterns(ADDITIONAL_KEYWORDS, - ADDITIONAL_BUILTINS), re.S) - IDPROG = re.compile(r"\s+([\w\.]+)", re.S) - - -#============================================================================== -# C/C++ syntax highlighter -#============================================================================== -C_KEYWORDS1 = 'and and_eq bitand bitor break case catch const const_cast continue default delete do dynamic_cast else explicit export extern for friend goto if inline namespace new not not_eq operator or or_eq private protected public register reinterpret_cast return sizeof static static_cast switch template throw try typedef typeid typename union using virtual while xor xor_eq' -C_KEYWORDS2 = 'a addindex addtogroup anchor arg attention author b brief bug c class code date def defgroup deprecated dontinclude e em endcode endhtmlonly ifdef endif endlatexonly endlink endverbatim enum example exception f$ file fn hideinitializer htmlinclude htmlonly if image include ingroup internal invariant interface latexonly li line link mainpage name namespace nosubgrouping note overload p page par param post pre ref relates remarks return retval sa section see showinitializer since skip skipline subsection test throw todo typedef union until var verbatim verbinclude version warning weakgroup' -C_KEYWORDS3 = 'asm auto class compl false true volatile wchar_t' - -def make_generic_c_patterns(keywords, builtins, - instance=None, define=None, comment=None): - "Strongly inspired from idlelib.ColorDelegator.make_pat" - kw = r"\b" + any("keyword", keywords.split()) + r"\b" - builtin = r"\b" + any("builtin", builtins.split()+C_TYPES.split()) + r"\b" - if comment is None: - comment = any("comment", [r"//[^\n]*", r"\/\*(.*?)\*\/"]) - comment_start = any("comment_start", [r"\/\*"]) - comment_end = any("comment_end", [r"\*\/"]) - if instance is None: - instance = any("instance", [r"\bthis\b"]) - number = any("number", - [r"\b[+-]?[0-9]+[lL]?\b", - r"\b[+-]?0[xX][0-9A-Fa-f]+[lL]?\b", - r"\b[+-]?[0-9]+(?:\.[0-9]+)?(?:[eE][+-]?[0-9]+)?\b"]) - sqstring = r"(\b[rRuU])?'[^'\\\n]*(\\.[^'\\\n]*)*'?" - dqstring = r'(\b[rRuU])?"[^"\\\n]*(\\.[^"\\\n]*)*"?' - string = any("string", [sqstring, dqstring]) - if define is None: - define = any("define", [r"#[^\n]*"]) - return "|".join([instance, kw, comment, string, number, - comment_start, comment_end, builtin, - define, any("SYNC", [r"\n"])]) - -def make_cpp_patterns(): - return make_generic_c_patterns(C_KEYWORDS1+' '+C_KEYWORDS2, C_KEYWORDS3) - -class CppSH(BaseSH): - """C/C++ Syntax Highlighter""" - # Syntax highlighting rules: - PROG = re.compile(make_cpp_patterns(), re.S) - # Syntax highlighting states (from one text block to another): - NORMAL = 0 - INSIDE_COMMENT = 1 - def __init__(self, parent, font=None, color_scheme=None): - BaseSH.__init__(self, parent, font, color_scheme) - - def highlight_block(self, text): - """Implement highlight specific for C/C++.""" - text = to_text_string(text) - inside_comment = tbh.get_state(self.currentBlock().previous()) == self.INSIDE_COMMENT - self.setFormat(0, qstring_length(text), - self.formats["comment" if inside_comment else "normal"]) - - index = 0 - for match in self.PROG.finditer(text): - for key, value in list(match.groupdict().items()): - if value: - start, end = get_span(match, key) - index += end-start - if key == "comment_start": - inside_comment = True - self.setFormat(start, qstring_length(text)-start, - self.formats["comment"]) - elif key == "comment_end": - inside_comment = False - self.setFormat(start, end-start, - self.formats["comment"]) - elif inside_comment: - self.setFormat(start, end-start, - self.formats["comment"]) - elif key == "define": - self.setFormat(start, end-start, - self.formats["number"]) - else: - self.setFormat(start, end-start, self.formats[key]) - - self.highlight_extras(text) - - last_state = self.INSIDE_COMMENT if inside_comment else self.NORMAL - tbh.set_state(self.currentBlock(), last_state) - - -def make_opencl_patterns(): - # Keywords: - kwstr1 = 'cl_char cl_uchar cl_short cl_ushort cl_int cl_uint cl_long cl_ulong cl_half cl_float cl_double cl_platform_id cl_device_id cl_context cl_command_queue cl_mem cl_program cl_kernel cl_event cl_sampler cl_bool cl_bitfield cl_device_type cl_platform_info cl_device_info cl_device_address_info cl_device_fp_config cl_device_mem_cache_type cl_device_local_mem_type cl_device_exec_capabilities cl_command_queue_properties cl_context_properties cl_context_info cl_command_queue_info cl_channel_order cl_channel_type cl_mem_flags cl_mem_object_type cl_mem_info cl_image_info cl_addressing_mode cl_filter_mode cl_sampler_info cl_map_flags cl_program_info cl_program_build_info cl_build_status cl_kernel_info cl_kernel_work_group_info cl_event_info cl_command_type cl_profiling_info cl_image_format' - # Constants: - kwstr2 = 'CL_FALSE, CL_TRUE, CL_PLATFORM_PROFILE, CL_PLATFORM_VERSION, CL_PLATFORM_NAME, CL_PLATFORM_VENDOR, CL_PLATFORM_EXTENSIONS, CL_DEVICE_TYPE_DEFAULT , CL_DEVICE_TYPE_CPU, CL_DEVICE_TYPE_GPU, CL_DEVICE_TYPE_ACCELERATOR, CL_DEVICE_TYPE_ALL, CL_DEVICE_TYPE, CL_DEVICE_VENDOR_ID, CL_DEVICE_MAX_COMPUTE_UNITS, CL_DEVICE_MAX_WORK_ITEM_DIMENSIONS, CL_DEVICE_MAX_WORK_GROUP_SIZE, CL_DEVICE_MAX_WORK_ITEM_SIZES, CL_DEVICE_PREFERRED_VECTOR_WIDTH_CHAR, CL_DEVICE_PREFERRED_VECTOR_WIDTH_SHORT, CL_DEVICE_PREFERRED_VECTOR_WIDTH_INT, CL_DEVICE_PREFERRED_VECTOR_WIDTH_LONG, CL_DEVICE_PREFERRED_VECTOR_WIDTH_FLOAT, CL_DEVICE_PREFERRED_VECTOR_WIDTH_DOUBLE, CL_DEVICE_MAX_CLOCK_FREQUENCY, CL_DEVICE_ADDRESS_BITS, CL_DEVICE_MAX_READ_IMAGE_ARGS, CL_DEVICE_MAX_WRITE_IMAGE_ARGS, CL_DEVICE_MAX_MEM_ALLOC_SIZE, CL_DEVICE_IMAGE2D_MAX_WIDTH, CL_DEVICE_IMAGE2D_MAX_HEIGHT, CL_DEVICE_IMAGE3D_MAX_WIDTH, CL_DEVICE_IMAGE3D_MAX_HEIGHT, CL_DEVICE_IMAGE3D_MAX_DEPTH, CL_DEVICE_IMAGE_SUPPORT, CL_DEVICE_MAX_PARAMETER_SIZE, CL_DEVICE_MAX_SAMPLERS, CL_DEVICE_MEM_BASE_ADDR_ALIGN, CL_DEVICE_MIN_DATA_TYPE_ALIGN_SIZE, CL_DEVICE_SINGLE_FP_CONFIG, CL_DEVICE_GLOBAL_MEM_CACHE_TYPE, CL_DEVICE_GLOBAL_MEM_CACHELINE_SIZE, CL_DEVICE_GLOBAL_MEM_CACHE_SIZE, CL_DEVICE_GLOBAL_MEM_SIZE, CL_DEVICE_MAX_CONSTANT_BUFFER_SIZE, CL_DEVICE_MAX_CONSTANT_ARGS, CL_DEVICE_LOCAL_MEM_TYPE, CL_DEVICE_LOCAL_MEM_SIZE, CL_DEVICE_ERROR_CORRECTION_SUPPORT, CL_DEVICE_PROFILING_TIMER_RESOLUTION, CL_DEVICE_ENDIAN_LITTLE, CL_DEVICE_AVAILABLE, CL_DEVICE_COMPILER_AVAILABLE, CL_DEVICE_EXECUTION_CAPABILITIES, CL_DEVICE_QUEUE_PROPERTIES, CL_DEVICE_NAME, CL_DEVICE_VENDOR, CL_DRIVER_VERSION, CL_DEVICE_PROFILE, CL_DEVICE_VERSION, CL_DEVICE_EXTENSIONS, CL_DEVICE_PLATFORM, CL_FP_DENORM, CL_FP_INF_NAN, CL_FP_ROUND_TO_NEAREST, CL_FP_ROUND_TO_ZERO, CL_FP_ROUND_TO_INF, CL_FP_FMA, CL_NONE, CL_READ_ONLY_CACHE, CL_READ_WRITE_CACHE, CL_LOCAL, CL_GLOBAL, CL_EXEC_KERNEL, CL_EXEC_NATIVE_KERNEL, CL_QUEUE_OUT_OF_ORDER_EXEC_MODE_ENABLE, CL_QUEUE_PROFILING_ENABLE, CL_CONTEXT_REFERENCE_COUNT, CL_CONTEXT_DEVICES, CL_CONTEXT_PROPERTIES, CL_CONTEXT_PLATFORM, CL_QUEUE_CONTEXT, CL_QUEUE_DEVICE, CL_QUEUE_REFERENCE_COUNT, CL_QUEUE_PROPERTIES, CL_MEM_READ_WRITE, CL_MEM_WRITE_ONLY, CL_MEM_READ_ONLY, CL_MEM_USE_HOST_PTR, CL_MEM_ALLOC_HOST_PTR, CL_MEM_COPY_HOST_PTR, CL_R, CL_A, CL_RG, CL_RA, CL_RGB, CL_RGBA, CL_BGRA, CL_ARGB, CL_INTENSITY, CL_LUMINANCE, CL_SNORM_INT8, CL_SNORM_INT16, CL_UNORM_INT8, CL_UNORM_INT16, CL_UNORM_SHORT_565, CL_UNORM_SHORT_555, CL_UNORM_INT_101010, CL_SIGNED_INT8, CL_SIGNED_INT16, CL_SIGNED_INT32, CL_UNSIGNED_INT8, CL_UNSIGNED_INT16, CL_UNSIGNED_INT32, CL_HALF_FLOAT, CL_FLOAT, CL_MEM_OBJECT_BUFFER, CL_MEM_OBJECT_IMAGE2D, CL_MEM_OBJECT_IMAGE3D, CL_MEM_TYPE, CL_MEM_FLAGS, CL_MEM_SIZECL_MEM_HOST_PTR, CL_MEM_HOST_PTR, CL_MEM_MAP_COUNT, CL_MEM_REFERENCE_COUNT, CL_MEM_CONTEXT, CL_IMAGE_FORMAT, CL_IMAGE_ELEMENT_SIZE, CL_IMAGE_ROW_PITCH, CL_IMAGE_SLICE_PITCH, CL_IMAGE_WIDTH, CL_IMAGE_HEIGHT, CL_IMAGE_DEPTH, CL_ADDRESS_NONE, CL_ADDRESS_CLAMP_TO_EDGE, CL_ADDRESS_CLAMP, CL_ADDRESS_REPEAT, CL_FILTER_NEAREST, CL_FILTER_LINEAR, CL_SAMPLER_REFERENCE_COUNT, CL_SAMPLER_CONTEXT, CL_SAMPLER_NORMALIZED_COORDS, CL_SAMPLER_ADDRESSING_MODE, CL_SAMPLER_FILTER_MODE, CL_MAP_READ, CL_MAP_WRITE, CL_PROGRAM_REFERENCE_COUNT, CL_PROGRAM_CONTEXT, CL_PROGRAM_NUM_DEVICES, CL_PROGRAM_DEVICES, CL_PROGRAM_SOURCE, CL_PROGRAM_BINARY_SIZES, CL_PROGRAM_BINARIES, CL_PROGRAM_BUILD_STATUS, CL_PROGRAM_BUILD_OPTIONS, CL_PROGRAM_BUILD_LOG, CL_BUILD_SUCCESS, CL_BUILD_NONE, CL_BUILD_ERROR, CL_BUILD_IN_PROGRESS, CL_KERNEL_FUNCTION_NAME, CL_KERNEL_NUM_ARGS, CL_KERNEL_REFERENCE_COUNT, CL_KERNEL_CONTEXT, CL_KERNEL_PROGRAM, CL_KERNEL_WORK_GROUP_SIZE, CL_KERNEL_COMPILE_WORK_GROUP_SIZE, CL_KERNEL_LOCAL_MEM_SIZE, CL_EVENT_COMMAND_QUEUE, CL_EVENT_COMMAND_TYPE, CL_EVENT_REFERENCE_COUNT, CL_EVENT_COMMAND_EXECUTION_STATUS, CL_COMMAND_NDRANGE_KERNEL, CL_COMMAND_TASK, CL_COMMAND_NATIVE_KERNEL, CL_COMMAND_READ_BUFFER, CL_COMMAND_WRITE_BUFFER, CL_COMMAND_COPY_BUFFER, CL_COMMAND_READ_IMAGE, CL_COMMAND_WRITE_IMAGE, CL_COMMAND_COPY_IMAGE, CL_COMMAND_COPY_IMAGE_TO_BUFFER, CL_COMMAND_COPY_BUFFER_TO_IMAGE, CL_COMMAND_MAP_BUFFER, CL_COMMAND_MAP_IMAGE, CL_COMMAND_UNMAP_MEM_OBJECT, CL_COMMAND_MARKER, CL_COMMAND_ACQUIRE_GL_OBJECTS, CL_COMMAND_RELEASE_GL_OBJECTS, command execution status, CL_COMPLETE, CL_RUNNING, CL_SUBMITTED, CL_QUEUED, CL_PROFILING_COMMAND_QUEUED, CL_PROFILING_COMMAND_SUBMIT, CL_PROFILING_COMMAND_START, CL_PROFILING_COMMAND_END, CL_CHAR_BIT, CL_SCHAR_MAX, CL_SCHAR_MIN, CL_CHAR_MAX, CL_CHAR_MIN, CL_UCHAR_MAX, CL_SHRT_MAX, CL_SHRT_MIN, CL_USHRT_MAX, CL_INT_MAX, CL_INT_MIN, CL_UINT_MAX, CL_LONG_MAX, CL_LONG_MIN, CL_ULONG_MAX, CL_FLT_DIG, CL_FLT_MANT_DIG, CL_FLT_MAX_10_EXP, CL_FLT_MAX_EXP, CL_FLT_MIN_10_EXP, CL_FLT_MIN_EXP, CL_FLT_RADIX, CL_FLT_MAX, CL_FLT_MIN, CL_FLT_EPSILON, CL_DBL_DIG, CL_DBL_MANT_DIG, CL_DBL_MAX_10_EXP, CL_DBL_MAX_EXP, CL_DBL_MIN_10_EXP, CL_DBL_MIN_EXP, CL_DBL_RADIX, CL_DBL_MAX, CL_DBL_MIN, CL_DBL_EPSILON, CL_SUCCESS, CL_DEVICE_NOT_FOUND, CL_DEVICE_NOT_AVAILABLE, CL_COMPILER_NOT_AVAILABLE, CL_MEM_OBJECT_ALLOCATION_FAILURE, CL_OUT_OF_RESOURCES, CL_OUT_OF_HOST_MEMORY, CL_PROFILING_INFO_NOT_AVAILABLE, CL_MEM_COPY_OVERLAP, CL_IMAGE_FORMAT_MISMATCH, CL_IMAGE_FORMAT_NOT_SUPPORTED, CL_BUILD_PROGRAM_FAILURE, CL_MAP_FAILURE, CL_INVALID_VALUE, CL_INVALID_DEVICE_TYPE, CL_INVALID_PLATFORM, CL_INVALID_DEVICE, CL_INVALID_CONTEXT, CL_INVALID_QUEUE_PROPERTIES, CL_INVALID_COMMAND_QUEUE, CL_INVALID_HOST_PTR, CL_INVALID_MEM_OBJECT, CL_INVALID_IMAGE_FORMAT_DESCRIPTOR, CL_INVALID_IMAGE_SIZE, CL_INVALID_SAMPLER, CL_INVALID_BINARY, CL_INVALID_BUILD_OPTIONS, CL_INVALID_PROGRAM, CL_INVALID_PROGRAM_EXECUTABLE, CL_INVALID_KERNEL_NAME, CL_INVALID_KERNEL_DEFINITION, CL_INVALID_KERNEL, CL_INVALID_ARG_INDEX, CL_INVALID_ARG_VALUE, CL_INVALID_ARG_SIZE, CL_INVALID_KERNEL_ARGS, CL_INVALID_WORK_DIMENSION, CL_INVALID_WORK_GROUP_SIZE, CL_INVALID_WORK_ITEM_SIZE, CL_INVALID_GLOBAL_OFFSET, CL_INVALID_EVENT_WAIT_LIST, CL_INVALID_EVENT, CL_INVALID_OPERATION, CL_INVALID_GL_OBJECT, CL_INVALID_BUFFER_SIZE, CL_INVALID_MIP_LEVEL, CL_INVALID_GLOBAL_WORK_SIZE' - # Functions: - builtins = 'clGetPlatformIDs, clGetPlatformInfo, clGetDeviceIDs, clGetDeviceInfo, clCreateContext, clCreateContextFromType, clReleaseContext, clGetContextInfo, clCreateCommandQueue, clRetainCommandQueue, clReleaseCommandQueue, clGetCommandQueueInfo, clSetCommandQueueProperty, clCreateBuffer, clCreateImage2D, clCreateImage3D, clRetainMemObject, clReleaseMemObject, clGetSupportedImageFormats, clGetMemObjectInfo, clGetImageInfo, clCreateSampler, clRetainSampler, clReleaseSampler, clGetSamplerInfo, clCreateProgramWithSource, clCreateProgramWithBinary, clRetainProgram, clReleaseProgram, clBuildProgram, clUnloadCompiler, clGetProgramInfo, clGetProgramBuildInfo, clCreateKernel, clCreateKernelsInProgram, clRetainKernel, clReleaseKernel, clSetKernelArg, clGetKernelInfo, clGetKernelWorkGroupInfo, clWaitForEvents, clGetEventInfo, clRetainEvent, clReleaseEvent, clGetEventProfilingInfo, clFlush, clFinish, clEnqueueReadBuffer, clEnqueueWriteBuffer, clEnqueueCopyBuffer, clEnqueueReadImage, clEnqueueWriteImage, clEnqueueCopyImage, clEnqueueCopyImageToBuffer, clEnqueueCopyBufferToImage, clEnqueueMapBuffer, clEnqueueMapImage, clEnqueueUnmapMemObject, clEnqueueNDRangeKernel, clEnqueueTask, clEnqueueNativeKernel, clEnqueueMarker, clEnqueueWaitForEvents, clEnqueueBarrier' - # Qualifiers: - qualifiers = '__global __local __constant __private __kernel' - keyword_list = C_KEYWORDS1+' '+C_KEYWORDS2+' '+kwstr1+' '+kwstr2 - builtin_list = C_KEYWORDS3+' '+builtins+' '+qualifiers - return make_generic_c_patterns(keyword_list, builtin_list) - -class OpenCLSH(CppSH): - """OpenCL Syntax Highlighter""" - PROG = re.compile(make_opencl_patterns(), re.S) - - -#============================================================================== -# Fortran Syntax Highlighter -#============================================================================== -def make_fortran_patterns(): - "Strongly inspired from idlelib.ColorDelegator.make_pat" - kwstr = 'access action advance allocatable allocate apostrophe assign assignment associate asynchronous backspace bind blank blockdata call case character class close common complex contains continue cycle data deallocate decimal delim default dimension direct do dowhile double doubleprecision else elseif elsewhere encoding end endassociate endblockdata enddo endfile endforall endfunction endif endinterface endmodule endprogram endselect endsubroutine endtype endwhere entry eor equivalence err errmsg exist exit external file flush fmt forall form format formatted function go goto id if implicit in include inout integer inquire intent interface intrinsic iomsg iolength iostat kind len logical module name named namelist nextrec nml none nullify number only open opened operator optional out pad parameter pass pause pending pointer pos position precision print private program protected public quote read readwrite real rec recl recursive result return rewind save select selectcase selecttype sequential sign size stat status stop stream subroutine target then to type unformatted unit use value volatile wait where while write' - bistr1 = 'abs achar acos acosd adjustl adjustr aimag aimax0 aimin0 aint ajmax0 ajmin0 akmax0 akmin0 all allocated alog alog10 amax0 amax1 amin0 amin1 amod anint any asin asind associated atan atan2 atan2d atand bitest bitl bitlr bitrl bjtest bit_size bktest break btest cabs ccos cdabs cdcos cdexp cdlog cdsin cdsqrt ceiling cexp char clog cmplx conjg cos cosd cosh count cpu_time cshift csin csqrt dabs dacos dacosd dasin dasind datan datan2 datan2d datand date date_and_time dble dcmplx dconjg dcos dcosd dcosh dcotan ddim dexp dfloat dflotk dfloti dflotj digits dim dimag dint dlog dlog10 dmax1 dmin1 dmod dnint dot_product dprod dreal dsign dsin dsind dsinh dsqrt dtan dtand dtanh eoshift epsilon errsns exp exponent float floati floatj floatk floor fraction free huge iabs iachar iand ibclr ibits ibset ichar idate idim idint idnint ieor ifix iiabs iiand iibclr iibits iibset iidim iidint iidnnt iieor iifix iint iior iiqint iiqnnt iishft iishftc iisign ilen imax0 imax1 imin0 imin1 imod index inint inot int int1 int2 int4 int8 iqint iqnint ior ishft ishftc isign isnan izext jiand jibclr jibits jibset jidim jidint jidnnt jieor jifix jint jior jiqint jiqnnt jishft jishftc jisign jmax0 jmax1 jmin0 jmin1 jmod jnint jnot jzext kiabs kiand kibclr kibits kibset kidim kidint kidnnt kieor kifix kind kint kior kishft kishftc kisign kmax0 kmax1 kmin0 kmin1 kmod knint knot kzext lbound leadz len len_trim lenlge lge lgt lle llt log log10 logical lshift malloc matmul max max0 max1 maxexponent maxloc maxval merge min min0 min1 minexponent minloc minval mod modulo mvbits nearest nint not nworkers number_of_processors pack popcnt poppar precision present product radix random random_number random_seed range real repeat reshape rrspacing rshift scale scan secnds selected_int_kind selected_real_kind set_exponent shape sign sin sind sinh size sizeof sngl snglq spacing spread sqrt sum system_clock tan tand tanh tiny transfer transpose trim ubound unpack verify' - bistr2 = 'cdabs cdcos cdexp cdlog cdsin cdsqrt cotan cotand dcmplx dconjg dcotan dcotand decode dimag dll_export dll_import doublecomplex dreal dvchk encode find flen flush getarg getcharqq getcl getdat getenv gettim hfix ibchng identifier imag int1 int2 int4 intc intrup invalop iostat_msg isha ishc ishl jfix lacfar locking locnear map nargs nbreak ndperr ndpexc offset ovefl peekcharqq precfill prompt qabs qacos qacosd qasin qasind qatan qatand qatan2 qcmplx qconjg qcos qcosd qcosh qdim qexp qext qextd qfloat qimag qlog qlog10 qmax1 qmin1 qmod qreal qsign qsin qsind qsinh qsqrt qtan qtand qtanh ran rand randu rewrite segment setdat settim system timer undfl unlock union val virtual volatile zabs zcos zexp zlog zsin zsqrt' - kw = r"\b" + any("keyword", kwstr.split()) + r"\b" - builtin = r"\b" + any("builtin", bistr1.split()+bistr2.split()) + r"\b" - comment = any("comment", [r"\![^\n]*"]) - number = any("number", - [r"\b[+-]?[0-9]+[lL]?\b", - r"\b[+-]?0[xX][0-9A-Fa-f]+[lL]?\b", - r"\b[+-]?[0-9]+(?:\.[0-9]+)?(?:[eE][+-]?[0-9]+)?\b"]) - sqstring = r"(\b[rRuU])?'[^'\\\n]*(\\.[^'\\\n]*)*'?" - dqstring = r'(\b[rRuU])?"[^"\\\n]*(\\.[^"\\\n]*)*"?' - string = any("string", [sqstring, dqstring]) - return "|".join([kw, comment, string, number, builtin, - any("SYNC", [r"\n"])]) - -class FortranSH(BaseSH): - """Fortran Syntax Highlighter""" - # Syntax highlighting rules: - PROG = re.compile(make_fortran_patterns(), re.S|re.I) - IDPROG = re.compile(r"\s+(\w+)", re.S) - # Syntax highlighting states (from one text block to another): - NORMAL = 0 - def __init__(self, parent, font=None, color_scheme=None): - BaseSH.__init__(self, parent, font, color_scheme) - - def highlight_block(self, text): - """Implement highlight specific for Fortran.""" - text = to_text_string(text) - self.setFormat(0, qstring_length(text), self.formats["normal"]) - - index = 0 - for match in self.PROG.finditer(text): - for key, value in list(match.groupdict().items()): - if value: - start, end = get_span(match, key) - index += end-start - self.setFormat(start, end-start, self.formats[key]) - if value.lower() in ("subroutine", "module", "function"): - match1 = self.IDPROG.match(text, end) - if match1: - start1, end1 = get_span(match1, 1) - self.setFormat(start1, end1-start1, - self.formats["definition"]) - - self.highlight_extras(text) - -class Fortran77SH(FortranSH): - """Fortran 77 Syntax Highlighter""" - def highlight_block(self, text): - """Implement highlight specific for Fortran77.""" - text = to_text_string(text) - if text.startswith(("c", "C")): - self.setFormat(0, qstring_length(text), self.formats["comment"]) - self.highlight_extras(text) - else: - FortranSH.highlight_block(self, text) - self.setFormat(0, 5, self.formats["comment"]) - self.setFormat(73, max([73, qstring_length(text)]), - self.formats["comment"]) - - -#============================================================================== -# IDL highlighter -# -# Contribution from Stuart Mumford (Littlemumford) - 2012-02-02 -# See spyder-ide/spyder#850. -#============================================================================== -def make_idl_patterns(): - """Strongly inspired by idlelib.ColorDelegator.make_pat.""" - kwstr = 'begin of pro function endfor endif endwhile endrep endcase endswitch end if then else for do while repeat until break case switch common continue exit return goto help message print read retall stop' - bistr1 = 'a_correlate abs acos adapt_hist_equal alog alog10 amoeba arg_present arra_equal array_indices ascii_template asin assoc atan beseli beselj besel k besely beta bilinear bin_date binary_template dinfgen dinomial blk_con broyden bytarr byte bytscl c_correlate call_external call_function ceil chebyshev check_math chisqr_cvf chisqr_pdf choldc cholsol cindgen clust_wts cluster color_quan colormap_applicable comfit complex complexarr complexround compute_mesh_normals cond congrid conj convert_coord convol coord2to3 correlate cos cosh cramer create_struct crossp crvlength ct_luminance cti_test curvefit cv_coord cvttobm cw_animate cw_arcball cw_bgroup cw_clr_index cw_colorsel cw_defroi cw_field cw_filesel cw_form cw_fslider cw_light_editor cw_orient cw_palette_editor cw_pdmenu cw_rgbslider cw_tmpl cw_zoom dblarr dcindgen dcomplexarr defroi deriv derivsig determ diag_matrix dialog_message dialog_pickfile pialog_printersetup dialog_printjob dialog_read_image dialog_write_image digital_filter dilate dindgen dist double eigenql eigenvec elmhes eof erode erf erfc erfcx execute exp expand_path expint extrac extract_slice f_cvf f_pdf factorial fft file_basename file_dirname file_expand_path file_info file_same file_search file_test file_which filepath findfile findgen finite fix float floor fltarr format_axis_values fstat fulstr fv_test fx_root fz_roots gamma gauss_cvf gauss_pdf gauss2dfit gaussfit gaussint get_drive_list get_kbrd get_screen_size getenv grid_tps grid3 griddata gs_iter hanning hdf_browser hdf_read hilbert hist_2d hist_equal histogram hough hqr ibeta identity idl_validname idlitsys_createtool igamma imaginary indgen int_2d int_3d int_tabulated intarr interpol interpolate invert ioctl ishft julday keword_set krig2d kurtosis kw_test l64indgen label_date label_region ladfit laguerre la_cholmprove la_cholsol la_Determ la_eigenproblem la_eigenql la_eigenvec la_elmhes la_gm_linear_model la_hqr la_invert la_least_square_equality la_least_squares la_linear_equation la_lumprove la_lusol la_trimprove la_trisol leefit legendre linbcg lindgen linfit ll_arc_distance lmfit lmgr lngamma lnp_test locale_get logical_and logical_or logical_true lon64arr lonarr long long64 lsode lu_complex lumprove lusol m_correlate machar make_array map_2points map_image map_patch map_proj_forward map_proj_init map_proj_inverse matrix_multiply matrix_power max md_test mean meanabsdev median memory mesh_clip mesh_decimate mesh_issolid mesh_merge mesh_numtriangles mesh_smooth mesh_surfacearea mesh_validate mesh_volume min min_curve_surf moment morph_close morph_distance morph_gradient morph_histormiss morph_open morph_thin morph_tophat mpeg_open msg_cat_open n_elements n_params n_tags newton norm obj_class obj_isa obj_new obj_valid objarr p_correlate path_sep pcomp pnt_line polar_surface poly poly_2d poly_area poly_fit polyfillv ployshade primes product profile profiles project_vol ptr_new ptr_valid ptrarr qgrid3 qromb qromo qsimp query_bmp query_dicom query_image query_jpeg query_mrsid query_pict query_png query_ppm query_srf query_tiff query_wav r_correlate r_test radon randomn randomu ranks read_ascii read_binary read_bmp read_dicom read_image read_mrsid read_png read_spr read_sylk read_tiff read_wav read_xwd real_part rebin recall_commands recon3 reform region_grow regress replicate reverse rk4 roberts rot rotate round routine_info rs_test s_test savgol search2d search3d sfit shift shmdebug shmvar simplex sin sindgen sinh size skewness smooth sobel sort sph_scat spher_harm spl_init spl_interp spline spline_p sprsab sprsax sprsin sprstp sqrt standardize stddev strarr strcmp strcompress stregex string strjoin strlen strlowcase strmatch strmessage strmid strpos strsplit strtrim strupcase svdfit svsol swap_endian systime t_cvf t_pdf tag_names tan tanh temporary tetra_clip tetra_surface tetra_volume thin timegen tm_test total trace transpose tri_surf trigrid trisol ts_coef ts_diff ts_fcast ts_smooth tvrd uindgen unit uintarr ul64indgen ulindgen ulon64arr ulonarr ulong ulong64 uniq value_locate variance vert_t3d voigt voxel_proj warp_tri watershed where widget_actevix widget_base widget_button widget_combobox widget_draw widget_droplist widget_event widget_info widget_label widget_list widget_propertsheet widget_slider widget_tab widget_table widget_text widget_tree write_sylk wtn xfont xregistered xsq_test' - bistr2 = 'annotate arrow axis bar_plot blas_axpy box_cursor breakpoint byteorder caldata calendar call_method call_procedure catch cd cir_3pnt close color_convert compile_opt constrained_min contour copy_lun cpu create_view cursor cw_animate_getp cw_animate_load cw_animate_run cw_light_editor_get cw_light_editor_set cw_palette_editor_get cw_palette_editor_set define_key define_msgblk define_msgblk_from_file defsysv delvar device dfpmin dissolve dlm_load doc_librar draw_roi efont empty enable_sysrtn erase errplot expand file_chmod file_copy file_delete file_lines file_link file_mkdir file_move file_readlink flick flow3 flush forward_function free_lun funct gamma_ct get_lun grid_input h_eq_ct h_eq_int heap_free heap_gc hls hsv icontour iimage image_cont image_statistics internal_volume iplot isocontour isosurface isurface itcurrent itdelete itgetcurrent itregister itreset ivolume journal la_choldc la_ludc la_svd la_tridc la_triql la_trired linkimage loadct ludc make_dll map_continents map_grid map_proj_info map_set mesh_obj mk_html_help modifyct mpeg_close mpeg_put mpeg_save msg_cat_close msg_cat_compile multi obj_destroy on_error on_ioerror online_help openr openw openu oplot oploterr particle_trace path_cache plot plot_3dbox plot_field ploterr plots point_lun polar_contour polyfill polywarp popd powell printf printd ps_show_fonts psafm pseudo ptr_free pushd qhull rdpix readf read_interfile read_jpeg read_pict read_ppm read_srf read_wave read_x11_bitmap reads readu reduce_colors register_cursor replicate_inplace resolve_all resolve_routine restore save scale3 scale3d set_plot set_shading setenv setup_keys shade_surf shade_surf_irr shade_volume shmmap show3 showfont skip_lun slicer3 slide_image socket spawn sph_4pnt streamline stretch strput struct_assign struct_hide surface surfr svdc swap_enian_inplace t3d tek_color threed time_test2 triangulate triql trired truncate_lun tv tvcrs tvlct tvscl usersym vector_field vel velovect voronoi wait wdelete wf_draw widget_control widget_displaycontextmenu window write_bmp write_image write_jpeg write_nrif write_pict write_png write_ppm write_spr write_srf write_tiff write_wav write_wave writeu wset wshow xbm_edit xdisplayfile xdxf xinteranimate xloadct xmanager xmng_tmpl xmtool xobjview xobjview_rotate xobjview_write_image xpalette xpcolo xplot3d xroi xsurface xvaredit xvolume xyouts zoom zoom_24' - kw = r"\b" + any("keyword", kwstr.split()) + r"\b" - builtin = r"\b" + any("builtin", bistr1.split()+bistr2.split()) + r"\b" - comment = any("comment", [r"\;[^\n]*"]) - number = any("number", - [r"\b[+-]?[0-9]+[lL]?\b", - r"\b[+-]?0[xX][0-9A-Fa-f]+[lL]?\b", - r"\b\.[0-9]d0|\.d0+[lL]?\b", - r"\b[+-]?[0-9]+(?:\.[0-9]+)?(?:[eE][+-]?[0-9]+)?\b"]) - sqstring = r"(\b[rRuU])?'[^'\\\n]*(\\.[^'\\\n]*)*'?" - dqstring = r'(\b[rRuU])?"[^"\\\n]*(\\.[^"\\\n]*)*"?' - string = any("string", [sqstring, dqstring]) - return "|".join([kw, comment, string, number, builtin, - any("SYNC", [r"\n"])]) - -class IdlSH(GenericSH): - """IDL Syntax Highlighter""" - PROG = re.compile(make_idl_patterns(), re.S|re.I) - - -#============================================================================== -# Diff/Patch highlighter -#============================================================================== -class DiffSH(BaseSH): - """Simple Diff/Patch Syntax Highlighter Class""" - def highlight_block(self, text): - """Implement highlight specific Diff/Patch files.""" - text = to_text_string(text) - if text.startswith("+++"): - self.setFormat(0, qstring_length(text), self.formats["keyword"]) - elif text.startswith("---"): - self.setFormat(0, qstring_length(text), self.formats["keyword"]) - elif text.startswith("+"): - self.setFormat(0, qstring_length(text), self.formats["string"]) - elif text.startswith("-"): - self.setFormat(0, qstring_length(text), self.formats["number"]) - elif text.startswith("@"): - self.setFormat(0, qstring_length(text), self.formats["builtin"]) - - self.highlight_extras(text) - -#============================================================================== -# NSIS highlighter -#============================================================================== -def make_nsis_patterns(): - "Strongly inspired from idlelib.ColorDelegator.make_pat" - kwstr1 = 'Abort AddBrandingImage AddSize AllowRootDirInstall AllowSkipFiles AutoCloseWindow BGFont BGGradient BrandingText BringToFront Call CallInstDLL Caption ClearErrors CompletedText ComponentText CopyFiles CRCCheck CreateDirectory CreateFont CreateShortCut Delete DeleteINISec DeleteINIStr DeleteRegKey DeleteRegValue DetailPrint DetailsButtonText DirText DirVar DirVerify EnableWindow EnumRegKey EnumRegValue Exec ExecShell ExecWait Exch ExpandEnvStrings File FileBufSize FileClose FileErrorText FileOpen FileRead FileReadByte FileSeek FileWrite FileWriteByte FindClose FindFirst FindNext FindWindow FlushINI Function FunctionEnd GetCurInstType GetCurrentAddress GetDlgItem GetDLLVersion GetDLLVersionLocal GetErrorLevel GetFileTime GetFileTimeLocal GetFullPathName GetFunctionAddress GetInstDirError GetLabelAddress GetTempFileName Goto HideWindow ChangeUI CheckBitmap Icon IfAbort IfErrors IfFileExists IfRebootFlag IfSilent InitPluginsDir InstallButtonText InstallColors InstallDir InstallDirRegKey InstProgressFlags InstType InstTypeGetText InstTypeSetText IntCmp IntCmpU IntFmt IntOp IsWindow LangString LicenseBkColor LicenseData LicenseForceSelection LicenseLangString LicenseText LoadLanguageFile LogSet LogText MessageBox MiscButtonText Name OutFile Page PageCallbacks PageEx PageExEnd Pop Push Quit ReadEnvStr ReadINIStr ReadRegDWORD ReadRegStr Reboot RegDLL Rename ReserveFile Return RMDir SearchPath Section SectionEnd SectionGetFlags SectionGetInstTypes SectionGetSize SectionGetText SectionIn SectionSetFlags SectionSetInstTypes SectionSetSize SectionSetText SendMessage SetAutoClose SetBrandingImage SetCompress SetCompressor SetCompressorDictSize SetCtlColors SetCurInstType SetDatablockOptimize SetDateSave SetDetailsPrint SetDetailsView SetErrorLevel SetErrors SetFileAttributes SetFont SetOutPath SetOverwrite SetPluginUnload SetRebootFlag SetShellVarContext SetSilent ShowInstDetails ShowUninstDetails ShowWindow SilentInstall SilentUnInstall Sleep SpaceTexts StrCmp StrCpy StrLen SubCaption SubSection SubSectionEnd UninstallButtonText UninstallCaption UninstallIcon UninstallSubCaption UninstallText UninstPage UnRegDLL Var VIAddVersionKey VIProductVersion WindowIcon WriteINIStr WriteRegBin WriteRegDWORD WriteRegExpandStr WriteRegStr WriteUninstaller XPStyle' - kwstr2 = 'all alwaysoff ARCHIVE auto both bzip2 components current custom details directory false FILE_ATTRIBUTE_ARCHIVE FILE_ATTRIBUTE_HIDDEN FILE_ATTRIBUTE_NORMAL FILE_ATTRIBUTE_OFFLINE FILE_ATTRIBUTE_READONLY FILE_ATTRIBUTE_SYSTEM FILE_ATTRIBUTE_TEMPORARY force grey HIDDEN hide IDABORT IDCANCEL IDIGNORE IDNO IDOK IDRETRY IDYES ifdiff ifnewer instfiles instfiles lastused leave left level license listonly lzma manual MB_ABORTRETRYIGNORE MB_DEFBUTTON1 MB_DEFBUTTON2 MB_DEFBUTTON3 MB_DEFBUTTON4 MB_ICONEXCLAMATION MB_ICONINFORMATION MB_ICONQUESTION MB_ICONSTOP MB_OK MB_OKCANCEL MB_RETRYCANCEL MB_RIGHT MB_SETFOREGROUND MB_TOPMOST MB_YESNO MB_YESNOCANCEL nevershow none NORMAL off OFFLINE on READONLY right RO show silent silentlog SYSTEM TEMPORARY text textonly true try uninstConfirm windows zlib' - kwstr3 = 'MUI_ABORTWARNING MUI_ABORTWARNING_CANCEL_DEFAULT MUI_ABORTWARNING_TEXT MUI_BGCOLOR MUI_COMPONENTSPAGE_CHECKBITMAP MUI_COMPONENTSPAGE_NODESC MUI_COMPONENTSPAGE_SMALLDESC MUI_COMPONENTSPAGE_TEXT_COMPLIST MUI_COMPONENTSPAGE_TEXT_DESCRIPTION_INFO MUI_COMPONENTSPAGE_TEXT_DESCRIPTION_TITLE MUI_COMPONENTSPAGE_TEXT_INSTTYPE MUI_COMPONENTSPAGE_TEXT_TOP MUI_CUSTOMFUNCTION_ABORT MUI_CUSTOMFUNCTION_GUIINIT MUI_CUSTOMFUNCTION_UNABORT MUI_CUSTOMFUNCTION_UNGUIINIT MUI_DESCRIPTION_TEXT MUI_DIRECTORYPAGE_BGCOLOR MUI_DIRECTORYPAGE_TEXT_DESTINATION MUI_DIRECTORYPAGE_TEXT_TOP MUI_DIRECTORYPAGE_VARIABLE MUI_DIRECTORYPAGE_VERIFYONLEAVE MUI_FINISHPAGE_BUTTON MUI_FINISHPAGE_CANCEL_ENABLED MUI_FINISHPAGE_LINK MUI_FINISHPAGE_LINK_COLOR MUI_FINISHPAGE_LINK_LOCATION MUI_FINISHPAGE_NOAUTOCLOSE MUI_FINISHPAGE_NOREBOOTSUPPORT MUI_FINISHPAGE_REBOOTLATER_DEFAULT MUI_FINISHPAGE_RUN MUI_FINISHPAGE_RUN_FUNCTION MUI_FINISHPAGE_RUN_NOTCHECKED MUI_FINISHPAGE_RUN_PARAMETERS MUI_FINISHPAGE_RUN_TEXT MUI_FINISHPAGE_SHOWREADME MUI_FINISHPAGE_SHOWREADME_FUNCTION MUI_FINISHPAGE_SHOWREADME_NOTCHECKED MUI_FINISHPAGE_SHOWREADME_TEXT MUI_FINISHPAGE_TEXT MUI_FINISHPAGE_TEXT_LARGE MUI_FINISHPAGE_TEXT_REBOOT MUI_FINISHPAGE_TEXT_REBOOTLATER MUI_FINISHPAGE_TEXT_REBOOTNOW MUI_FINISHPAGE_TITLE MUI_FINISHPAGE_TITLE_3LINES MUI_FUNCTION_DESCRIPTION_BEGIN MUI_FUNCTION_DESCRIPTION_END MUI_HEADER_TEXT MUI_HEADER_TRANSPARENT_TEXT MUI_HEADERIMAGE MUI_HEADERIMAGE_BITMAP MUI_HEADERIMAGE_BITMAP_NOSTRETCH MUI_HEADERIMAGE_BITMAP_RTL MUI_HEADERIMAGE_BITMAP_RTL_NOSTRETCH MUI_HEADERIMAGE_RIGHT MUI_HEADERIMAGE_UNBITMAP MUI_HEADERIMAGE_UNBITMAP_NOSTRETCH MUI_HEADERIMAGE_UNBITMAP_RTL MUI_HEADERIMAGE_UNBITMAP_RTL_NOSTRETCH MUI_HWND MUI_ICON MUI_INSTALLCOLORS MUI_INSTALLOPTIONS_DISPLAY MUI_INSTALLOPTIONS_DISPLAY_RETURN MUI_INSTALLOPTIONS_EXTRACT MUI_INSTALLOPTIONS_EXTRACT_AS MUI_INSTALLOPTIONS_INITDIALOG MUI_INSTALLOPTIONS_READ MUI_INSTALLOPTIONS_SHOW MUI_INSTALLOPTIONS_SHOW_RETURN MUI_INSTALLOPTIONS_WRITE MUI_INSTFILESPAGE_ABORTHEADER_SUBTEXT MUI_INSTFILESPAGE_ABORTHEADER_TEXT MUI_INSTFILESPAGE_COLORS MUI_INSTFILESPAGE_FINISHHEADER_SUBTEXT MUI_INSTFILESPAGE_FINISHHEADER_TEXT MUI_INSTFILESPAGE_PROGRESSBAR MUI_LANGDLL_ALLLANGUAGES MUI_LANGDLL_ALWAYSSHOW MUI_LANGDLL_DISPLAY MUI_LANGDLL_INFO MUI_LANGDLL_REGISTRY_KEY MUI_LANGDLL_REGISTRY_ROOT MUI_LANGDLL_REGISTRY_VALUENAME MUI_LANGDLL_WINDOWTITLE MUI_LANGUAGE MUI_LICENSEPAGE_BGCOLOR MUI_LICENSEPAGE_BUTTON MUI_LICENSEPAGE_CHECKBOX MUI_LICENSEPAGE_CHECKBOX_TEXT MUI_LICENSEPAGE_RADIOBUTTONS MUI_LICENSEPAGE_RADIOBUTTONS_TEXT_ACCEPT MUI_LICENSEPAGE_RADIOBUTTONS_TEXT_DECLINE MUI_LICENSEPAGE_TEXT_BOTTOM MUI_LICENSEPAGE_TEXT_TOP MUI_PAGE_COMPONENTS MUI_PAGE_CUSTOMFUNCTION_LEAVE MUI_PAGE_CUSTOMFUNCTION_PRE MUI_PAGE_CUSTOMFUNCTION_SHOW MUI_PAGE_DIRECTORY MUI_PAGE_FINISH MUI_PAGE_HEADER_SUBTEXT MUI_PAGE_HEADER_TEXT MUI_PAGE_INSTFILES MUI_PAGE_LICENSE MUI_PAGE_STARTMENU MUI_PAGE_WELCOME MUI_RESERVEFILE_INSTALLOPTIONS MUI_RESERVEFILE_LANGDLL MUI_SPECIALINI MUI_STARTMENU_GETFOLDER MUI_STARTMENU_WRITE_BEGIN MUI_STARTMENU_WRITE_END MUI_STARTMENUPAGE_BGCOLOR MUI_STARTMENUPAGE_DEFAULTFOLDER MUI_STARTMENUPAGE_NODISABLE MUI_STARTMENUPAGE_REGISTRY_KEY MUI_STARTMENUPAGE_REGISTRY_ROOT MUI_STARTMENUPAGE_REGISTRY_VALUENAME MUI_STARTMENUPAGE_TEXT_CHECKBOX MUI_STARTMENUPAGE_TEXT_TOP MUI_UI MUI_UI_COMPONENTSPAGE_NODESC MUI_UI_COMPONENTSPAGE_SMALLDESC MUI_UI_HEADERIMAGE MUI_UI_HEADERIMAGE_RIGHT MUI_UNABORTWARNING MUI_UNABORTWARNING_CANCEL_DEFAULT MUI_UNABORTWARNING_TEXT MUI_UNCONFIRMPAGE_TEXT_LOCATION MUI_UNCONFIRMPAGE_TEXT_TOP MUI_UNFINISHPAGE_NOAUTOCLOSE MUI_UNFUNCTION_DESCRIPTION_BEGIN MUI_UNFUNCTION_DESCRIPTION_END MUI_UNGETLANGUAGE MUI_UNICON MUI_UNPAGE_COMPONENTS MUI_UNPAGE_CONFIRM MUI_UNPAGE_DIRECTORY MUI_UNPAGE_FINISH MUI_UNPAGE_INSTFILES MUI_UNPAGE_LICENSE MUI_UNPAGE_WELCOME MUI_UNWELCOMEFINISHPAGE_BITMAP MUI_UNWELCOMEFINISHPAGE_BITMAP_NOSTRETCH MUI_UNWELCOMEFINISHPAGE_INI MUI_WELCOMEFINISHPAGE_BITMAP MUI_WELCOMEFINISHPAGE_BITMAP_NOSTRETCH MUI_WELCOMEFINISHPAGE_CUSTOMFUNCTION_INIT MUI_WELCOMEFINISHPAGE_INI MUI_WELCOMEPAGE_TEXT MUI_WELCOMEPAGE_TITLE MUI_WELCOMEPAGE_TITLE_3LINES' - bistr = 'addincludedir addplugindir AndIf cd define echo else endif error execute If ifdef ifmacrodef ifmacrondef ifndef include insertmacro macro macroend onGUIEnd onGUIInit onInit onInstFailed onInstSuccess onMouseOverSection onRebootFailed onSelChange onUserAbort onVerifyInstDir OrIf packhdr system undef verbose warning' - instance = any("instance", [r'\$\{.*?\}', r'\$[A-Za-z0-9\_]*']) - define = any("define", [r"\![^\n]*"]) - comment = any("comment", [r"\;[^\n]*", r"\#[^\n]*", r"\/\*(.*?)\*\/"]) - return make_generic_c_patterns(kwstr1+' '+kwstr2+' '+kwstr3, bistr, - instance=instance, define=define, - comment=comment) - -class NsisSH(CppSH): - """NSIS Syntax Highlighter""" - # Syntax highlighting rules: - PROG = re.compile(make_nsis_patterns(), re.S) - - -#============================================================================== -# gettext highlighter -#============================================================================== -def make_gettext_patterns(): - "Strongly inspired from idlelib.ColorDelegator.make_pat" - kwstr = 'msgid msgstr' - kw = r"\b" + any("keyword", kwstr.split()) + r"\b" - fuzzy = any("builtin", [r"#,[^\n]*"]) - links = any("normal", [r"#:[^\n]*"]) - comment = any("comment", [r"#[^\n]*"]) - number = any("number", - [r"\b[+-]?[0-9]+[lL]?\b", - r"\b[+-]?0[xX][0-9A-Fa-f]+[lL]?\b", - r"\b[+-]?[0-9]+(?:\.[0-9]+)?(?:[eE][+-]?[0-9]+)?\b"]) - sqstring = r"(\b[rRuU])?'[^'\\\n]*(\\.[^'\\\n]*)*'?" - dqstring = r'(\b[rRuU])?"[^"\\\n]*(\\.[^"\\\n]*)*"?' - string = any("string", [sqstring, dqstring]) - return "|".join([kw, string, number, fuzzy, links, comment, - any("SYNC", [r"\n"])]) - -class GetTextSH(GenericSH): - """gettext Syntax Highlighter""" - # Syntax highlighting rules: - PROG = re.compile(make_gettext_patterns(), re.S) - -#============================================================================== -# yaml highlighter -#============================================================================== -def make_yaml_patterns(): - "Strongly inspired from sublime highlighter " - kw = any("keyword", [r":|>|-|\||\[|\]|[A-Za-z][\w\s\-\_ ]+(?=:)"]) - links = any("normal", [r"#:[^\n]*"]) - comment = any("comment", [r"#[^\n]*"]) - number = any("number", - [r"\b[+-]?[0-9]+[lL]?\b", - r"\b[+-]?0[xX][0-9A-Fa-f]+[lL]?\b", - r"\b[+-]?[0-9]+(?:\.[0-9]+)?(?:[eE][+-]?[0-9]+)?\b"]) - sqstring = r"(\b[rRuU])?'[^'\\\n]*(\\.[^'\\\n]*)*'?" - dqstring = r'(\b[rRuU])?"[^"\\\n]*(\\.[^"\\\n]*)*"?' - string = any("string", [sqstring, dqstring]) - return "|".join([kw, string, number, links, comment, - any("SYNC", [r"\n"])]) - -class YamlSH(GenericSH): - """yaml Syntax Highlighter""" - # Syntax highlighting rules: - PROG = re.compile(make_yaml_patterns(), re.S) - - -#============================================================================== -# HTML highlighter -#============================================================================== -class BaseWebSH(BaseSH): - """Base class for CSS and HTML syntax highlighters""" - NORMAL = 0 - COMMENT = 1 - - def __init__(self, parent, font=None, color_scheme=None): - BaseSH.__init__(self, parent, font, color_scheme) - - def highlight_block(self, text): - """Implement highlight specific for CSS and HTML.""" - text = to_text_string(text) - previous_state = tbh.get_state(self.currentBlock().previous()) - - if previous_state == self.COMMENT: - self.setFormat(0, qstring_length(text), self.formats["comment"]) - else: - previous_state = self.NORMAL - self.setFormat(0, qstring_length(text), self.formats["normal"]) - - tbh.set_state(self.currentBlock(), previous_state) - - match_count = 0 - n_characters = qstring_length(text) - # There should never be more matches than characters in the text. - for match in self.PROG.finditer(text): - match_dict = match.groupdict() - for key, value in list(match_dict.items()): - if value: - start, end = get_span(match, key) - if previous_state == self.COMMENT: - if key == "multiline_comment_end": - tbh.set_state(self.currentBlock(), self.NORMAL) - self.setFormat(end, qstring_length(text), - self.formats["normal"]) - else: - tbh.set_state(self.currentBlock(), self.COMMENT) - self.setFormat(0, qstring_length(text), - self.formats["comment"]) - else: - if key == "multiline_comment_start": - tbh.set_state(self.currentBlock(), self.COMMENT) - self.setFormat(start, qstring_length(text), - self.formats["comment"]) - else: - tbh.set_state(self.currentBlock(), self.NORMAL) - try: - self.setFormat(start, end-start, - self.formats[key]) - except KeyError: - # Happens with unmatched end-of-comment. - # See spyder-ide/spyder#1462. - pass - match_count += 1 - if match_count >= n_characters: - break - - self.highlight_extras(text) - -def make_html_patterns(): - """Strongly inspired from idlelib.ColorDelegator.make_pat """ - tags = any("builtin", [r"<", r"[\?/]?>", r"(?<=<).*?(?=[ >])"]) - keywords = any("keyword", [r" [\w:-]*?(?==)"]) - string = any("string", [r'".*?"']) - comment = any("comment", [r""]) - multiline_comment_start = any("multiline_comment_start", [r""]) - return "|".join([comment, multiline_comment_start, - multiline_comment_end, tags, keywords, string]) - -class HtmlSH(BaseWebSH): - """HTML Syntax Highlighter""" - PROG = re.compile(make_html_patterns(), re.S) - - -# ============================================================================= -# Markdown highlighter -# ============================================================================= -def make_md_patterns(): - h1 = '^#[^#]+' - h2 = '^##[^#]+' - h3 = '^###[^#]+' - h4 = '^####[^#]+' - h5 = '^#####[^#]+' - h6 = '^######[^#]+' - - titles = any('title', [h1, h2, h3, h4, h5, h6]) - - html_tags = any("builtin", [r"<", r"[\?/]?>", r"(?<=<).*?(?=[ >])"]) - html_symbols = '&[^; ].+;' - html_comment = '' - - strikethrough = any('strikethrough', [r'(~~)(.*?)~~']) - strong = any('strong', [r'(\*\*)(.*?)\*\*']) - - italic = r'(__)(.*?)__' - emphasis = r'(//)(.*?)//' - italic = any('italic', [italic, emphasis]) - - # links - (links) after [] or links after []: - link_html = (r'(?<=(\]\())[^\(\)]*(?=\))|' - '(]+>)|' - '(<[^ >]+@[^ >]+>)') - # link/image references - [] or ![] - link = r'!?\[[^\[\]]*\]' - links = any('link', [link_html, link]) - - # blockquotes and lists - > or - or * or 0. - blockquotes = (r'(^>+.*)' - r'|(^(?: |\t)*[0-9]+\. )' - r'|(^(?: |\t)*- )' - r'|(^(?: |\t)*\* )') - # code - code = any('code', ['^`{3,}.*$']) - inline_code = any('inline_code', ['`[^`]*`']) - - # math - $$ - math = any('number', [r'^(?:\${2}).*$', html_symbols]) - - comment = any('comment', [blockquotes, html_comment]) - - return '|'.join([titles, comment, html_tags, math, links, italic, strong, - strikethrough, code, inline_code]) - - -class MarkdownSH(BaseSH): - """Markdown Syntax Highlighter""" - # Syntax highlighting rules: - PROG = re.compile(make_md_patterns(), re.S) - NORMAL = 0 - CODE = 1 - - def highlightBlock(self, text): - text = to_text_string(text) - previous_state = self.previousBlockState() - - if previous_state == self.CODE: - self.setFormat(0, qstring_length(text), self.formats["code"]) - else: - previous_state = self.NORMAL - self.setFormat(0, qstring_length(text), self.formats["normal"]) - - self.setCurrentBlockState(previous_state) - - match_count = 0 - n_characters = qstring_length(text) - for match in self.PROG.finditer(text): - for key, value in list(match.groupdict().items()): - start, end = get_span(match, key) - - if value: - previous_state = self.previousBlockState() - - if previous_state == self.CODE: - if key == "code": - # Change to normal - self.setFormat(0, qstring_length(text), - self.formats["normal"]) - self.setCurrentBlockState(self.NORMAL) - else: - continue - else: - if key == "code": - # Change to code - self.setFormat(0, qstring_length(text), - self.formats["code"]) - self.setCurrentBlockState(self.CODE) - continue - - self.setFormat(start, end - start, self.formats[key]) - - match_count += 1 - if match_count >= n_characters: - break - - self.highlight_extras(text) - - def setup_formats(self, font=None): - super(MarkdownSH, self).setup_formats(font) - - font = QTextCharFormat(self.formats['normal']) - font.setFontItalic(True) - self.formats['italic'] = font - - self.formats['strong'] = self.formats['definition'] - - font = QTextCharFormat(self.formats['normal']) - font.setFontStrikeOut(True) - self.formats['strikethrough'] = font - - font = QTextCharFormat(self.formats['string']) - font.setUnderlineStyle(QTextCharFormat.SingleUnderline) - self.formats['link'] = font - - self.formats['code'] = self.formats['string'] - self.formats['inline_code'] = self.formats['string'] - - font = QTextCharFormat(self.formats['keyword']) - font.setFontWeight(QFont.Bold) - self.formats['title'] = font - - -#============================================================================== -# Pygments based omni-parser -#============================================================================== -# IMPORTANT NOTE: -# -------------- -# Do not be tempted to generalize the use of PygmentsSH (that is tempting -# because it would lead to more generic and compact code, and not only in -# this very module) because this generic syntax highlighter is far slower -# than the native ones (all classes above). For example, a Python syntax -# highlighter based on PygmentsSH would be 2 to 3 times slower than the -# current native PythonSH syntax highlighter. - -class PygmentsSH(BaseSH): - """Generic Pygments syntax highlighter.""" - # Store the language name and a ref to the lexer - _lang_name = None - _lexer = None - - # Syntax highlighting states (from one text block to another): - NORMAL = 0 - def __init__(self, parent, font=None, color_scheme=None): - # Map Pygments tokens to Spyder tokens - self._tokmap = {Text: "normal", - Generic: "normal", - Other: "normal", - Keyword: "keyword", - Token.Operator: "normal", - Name.Builtin: "builtin", - Name: "normal", - Comment: "comment", - String: "string", - Number: "number"} - # Load Pygments' Lexer - if self._lang_name is not None: - self._lexer = get_lexer_by_name(self._lang_name) - - BaseSH.__init__(self, parent, font, color_scheme) - - # This worker runs in a thread to avoid blocking when doing full file - # parsing - self._worker_manager = WorkerManager() - - # Store the format for all the tokens after Pygments parsing - self._charlist = [] - - # Flag variable to avoid unnecessary highlights if the worker has not - # yet finished processing - self._allow_highlight = True - - def stop(self): - self._worker_manager.terminate_all() - - def make_charlist(self): - """Parses the complete text and stores format for each character.""" - - def worker_output(worker, output, error): - """Worker finished callback.""" - self._charlist = output - if error is None and output: - self._allow_highlight = True - self.rehighlight() - self._allow_highlight = False - - text = to_text_string(self.document().toPlainText()) - tokens = self._lexer.get_tokens(text) - - # Before starting a new worker process make sure to end previous - # incarnations - self._worker_manager.terminate_all() - - worker = self._worker_manager.create_python_worker( - self._make_charlist, - tokens, - self._tokmap, - self.formats, - ) - worker.sig_finished.connect(worker_output) - worker.start() - - def _make_charlist(self, tokens, tokmap, formats): - """ - Parses the complete text and stores format for each character. - - Uses the attached lexer to parse into a list of tokens and Pygments - token types. Then breaks tokens into individual letters, each with a - Spyder token type attached. Stores this list as self._charlist. - - It's attached to the contentsChange signal of the parent QTextDocument - so that the charlist is updated whenever the document changes. - """ - - def _get_fmt(typ): - """Get the Spyder format code for the given Pygments token type.""" - # Exact matches first - if typ in tokmap: - return tokmap[typ] - # Partial (parent-> child) matches - for key, val in tokmap.items(): - if typ in key: # Checks if typ is a subtype of key. - return val - - return 'normal' - - charlist = [] - for typ, token in tokens: - fmt = formats[_get_fmt(typ)] - for letter in token: - charlist.append((fmt, letter)) - - return charlist - - def highlightBlock(self, text): - """ Actually highlight the block""" - # Note that an undefined blockstate is equal to -1, so the first block - # will have the correct behaviour of starting at 0. - if self._allow_highlight: - start = self.previousBlockState() + 1 - end = start + qstring_length(text) - for i, (fmt, letter) in enumerate(self._charlist[start:end]): - self.setFormat(i, 1, fmt) - self.setCurrentBlockState(end) - self.highlight_extras(text) - - -class PythonLoggingLexer(RegexLexer): - """ - A lexer for logs generated by the Python builtin 'logging' library. - - Taken from - https://bitbucket.org/birkenfeld/pygments-main/pull-requests/451/add-python-logging-lexer - """ - - name = 'Python Logging' - aliases = ['pylog', 'pythonlogging'] - filenames = ['*.log'] - tokens = { - 'root': [ - (r'^(\d{4}-\d\d-\d\d \d\d:\d\d:\d\d\,?\d*)(\s\w+)', - bygroups(Comment.Preproc, Number.Integer), 'message'), - (r'"(.*?)"|\'(.*?)\'', String), - (r'(\d)', Number.Integer), - (r'(\s.+/n)', Text) - ], - - 'message': [ - (r'(\s-)(\sDEBUG)(\s-)(\s*[\d\w]+([.]?[\d\w]+)+\s*)', - bygroups(Text, Number, Text, Name.Builtin), '#pop'), - (r'(\s-)(\sINFO\w*)(\s-)(\s*[\d\w]+([.]?[\d\w]+)+\s*)', - bygroups(Generic.Heading, Text, Text, Name.Builtin), '#pop'), - (r'(\sWARN\w*)(\s.+)', bygroups(String, String), '#pop'), - (r'(\sERROR)(\s.+)', - bygroups(Generic.Error, Name.Constant), '#pop'), - (r'(\sCRITICAL)(\s.+)', - bygroups(Generic.Error, Name.Constant), '#pop'), - (r'(\sTRACE)(\s.+)', - bygroups(Generic.Error, Name.Constant), '#pop'), - (r'(\s\w+)(\s.+)', - bygroups(Comment, Generic.Output), '#pop'), - ], - - } - - -def guess_pygments_highlighter(filename): - """ - Factory to generate syntax highlighter for the given filename. - - If a syntax highlighter is not available for a particular file, this - function will attempt to generate one based on the lexers in Pygments. If - Pygments is not available or does not have an appropriate lexer, TextSH - will be returned instead. - """ - try: - from pygments.lexers import get_lexer_for_filename, get_lexer_by_name - except Exception: - return TextSH - - root, ext = os.path.splitext(filename) - if ext == '.txt': - # Pygments assigns a lexer that doesn’t highlight anything to - # txt files. So we avoid that here. - return TextSH - elif ext in custom_extension_lexer_mapping: - try: - lexer = get_lexer_by_name(custom_extension_lexer_mapping[ext]) - except Exception: - return TextSH - elif ext == '.log': - lexer = PythonLoggingLexer() - else: - try: - lexer = get_lexer_for_filename(filename) - except Exception: - return TextSH - - class GuessedPygmentsSH(PygmentsSH): - _lexer = lexer - - return GuessedPygmentsSH +# -*- coding: utf-8 -*- +# +# Copyright © Spyder Project Contributors +# Licensed under the terms of the MIT License +# (see spyder/__init__.py for details) + +""" +Editor widget syntax highlighters based on QtGui.QSyntaxHighlighter +(Python syntax highlighting rules are inspired from idlelib) +""" + +# Standard library imports +from __future__ import print_function +import keyword +import os +import re +import weakref + +# Third party imports +from pygments.lexer import RegexLexer, bygroups +from pygments.lexers import get_lexer_by_name +from pygments.token import (Text, Other, Keyword, Name, String, Number, + Comment, Generic, Token) +from qtpy.QtCore import Qt, QTimer, Signal +from qtpy.QtGui import (QColor, QCursor, QFont, QSyntaxHighlighter, + QTextCharFormat, QTextOption) +from qtpy.QtWidgets import QApplication + +# Local imports +from spyder.config.base import _ +from spyder.config.manager import CONF +from spyder.py3compat import (builtins, is_text_string, to_text_string, PY3, + PY36_OR_MORE) +from spyder.plugins.editor.utils.languages import CELL_LANGUAGES +from spyder.plugins.editor.utils.editor import TextBlockHelper as tbh +from spyder.plugins.editor.utils.editor import BlockUserData +from spyder.utils.workers import WorkerManager +from spyder.plugins.outlineexplorer.api import OutlineExplorerData +from spyder.utils.qstringhelpers import qstring_length + + + +# ============================================================================= +# Constants +# ============================================================================= +DEFAULT_PATTERNS = { + 'file': + r'file:///?(?:[\S]*)', + 'issue': + (r'(?:(?:(?:gh:)|(?:gl:)|(?:bb:))?[\w\-_]*/[\w\-_]*#\d+)|' + r'(?:(?:(?:gh-)|(?:gl-)|(?:bb-))\d+)'), + 'mail': + r'(?:mailto:\s*)?([a-z0-9_\.-]+)@([\da-z\.-]+)\.([a-z\.]{2,6})', + 'url': + r"https?://([\da-z\.-]+)\.([a-z\.]{2,6})([/\w\.-]*)[^ ^'^\"]+", +} + +COLOR_SCHEME_KEYS = { + "background": _("Background:"), + "currentline": _("Current line:"), + "currentcell": _("Current cell:"), + "occurrence": _("Occurrence:"), + "ctrlclick": _("Link:"), + "sideareas": _("Side areas:"), + "matched_p": _("Matched
    parens:"), + "unmatched_p": _("Unmatched
    parens:"), + "normal": _("Normal text:"), + "keyword": _("Keyword:"), + "builtin": _("Builtin:"), + "definition": _("Definition:"), + "comment": _("Comment:"), + "string": _("String:"), + "number": _("Number:"), + "instance": _("Instance:"), + "magic": _("Magic:"), +} + +COLOR_SCHEME_DEFAULT_VALUES = { + "background": "#19232D", + "currentline": "#3a424a", + "currentcell": "#292d3e", + "occurrence": "#1A72BB", + "ctrlclick": "#179ae0", + "sideareas": "#222b35", + "matched_p": "#0bbe0b", + "unmatched_p": "#ff4340", + "normal": ("#ffffff", False, False), + "keyword": ("#c670e0", False, False), + "builtin": ("#fab16c", False, False), + "definition": ("#57d6e4", True, False), + "comment": ("#999999", False, False), + "string": ("#b0e686", False, True), + "number": ("#faed5c", False, False), + "instance": ("#ee6772", False, True), + "magic": ("#c670e0", False, False), +} + +COLOR_SCHEME_NAMES = CONF.get('appearance', 'names') + +# Mapping for file extensions that use Pygments highlighting but should use +# different lexers than Pygments' autodetection suggests. Keys are file +# extensions or tuples of extensions, values are Pygments lexer names. +CUSTOM_EXTENSION_LEXER = { + '.ipynb': 'json', + '.nt': 'bat', + '.m': 'matlab', + ('.properties', '.session', '.inf', '.reg', '.url', + '.cfg', '.cnf', '.aut', '.iss'): 'ini' +} + +# Convert custom extensions into a one-to-one mapping for easier lookup. +custom_extension_lexer_mapping = {} +for key, value in CUSTOM_EXTENSION_LEXER.items(): + # Single key is mapped unchanged. + if is_text_string(key): + custom_extension_lexer_mapping[key] = value + # Tuple of keys is iterated over and each is mapped to value. + else: + for k in key: + custom_extension_lexer_mapping[k] = value + + +#============================================================================== +# Auxiliary functions +#============================================================================== +def get_span(match, key=None): + if key is not None: + start, end = match.span(key) + else: + start, end = match.span() + start16 = qstring_length(match.string[:start]) + end16 = start16 + qstring_length(match.string[start:end]) + return start16, end16 + + +def get_color_scheme(name): + """Get a color scheme from config using its name""" + name = name.lower() + scheme = {} + for key in COLOR_SCHEME_KEYS: + try: + scheme[key] = CONF.get('appearance', name+'/'+key) + except: + scheme[key] = CONF.get('appearance', 'spyder/'+key) + return scheme + + +def any(name, alternates): + "Return a named group pattern matching list of alternates." + return "(?P<%s>" % name + "|".join(alternates) + ")" + + +def create_patterns(patterns, compile=False): + """ + Create patterns from pattern dictionary. + + The key correspond to the group name and the values a list of + possible pattern alternatives. + """ + all_patterns = [] + for key, value in patterns.items(): + all_patterns.append(any(key, [value])) + + regex = '|'.join(all_patterns) + + if compile: + regex = re.compile(regex) + + return regex + + +DEFAULT_PATTERNS_TEXT = create_patterns(DEFAULT_PATTERNS, compile=False) +DEFAULT_COMPILED_PATTERNS = re.compile(create_patterns(DEFAULT_PATTERNS, + compile=True)) + + +#============================================================================== +# Syntax highlighting color schemes +#============================================================================== +class BaseSH(QSyntaxHighlighter): + """Base Syntax Highlighter Class""" + # Syntax highlighting rules: + PROG = None + BLANKPROG = re.compile(r"\s+") + # Syntax highlighting states (from one text block to another): + NORMAL = 0 + # Syntax highlighting parameters. + BLANK_ALPHA_FACTOR = 0.31 + + sig_outline_explorer_data_changed = Signal() + + # Use to signal font change + sig_font_changed = Signal() + + def __init__(self, parent, font=None, color_scheme='Spyder'): + QSyntaxHighlighter.__init__(self, parent) + + self.font = font + if is_text_string(color_scheme): + self.color_scheme = get_color_scheme(color_scheme) + else: + self.color_scheme = color_scheme + + self.background_color = None + self.currentline_color = None + self.currentcell_color = None + self.occurrence_color = None + self.ctrlclick_color = None + self.sideareas_color = None + self.matched_p_color = None + self.unmatched_p_color = None + + self.formats = None + self.setup_formats(font) + + self.cell_separators = None + self.editor = None + self.patterns = DEFAULT_COMPILED_PATTERNS + + # List of cells + self._cell_list = [] + + def get_background_color(self): + return QColor(self.background_color) + + def get_foreground_color(self): + """Return foreground ('normal' text) color""" + return self.formats["normal"].foreground().color() + + def get_currentline_color(self): + return QColor(self.currentline_color) + + def get_currentcell_color(self): + return QColor(self.currentcell_color) + + def get_occurrence_color(self): + return QColor(self.occurrence_color) + + def get_ctrlclick_color(self): + return QColor(self.ctrlclick_color) + + def get_sideareas_color(self): + return QColor(self.sideareas_color) + + def get_matched_p_color(self): + return QColor(self.matched_p_color) + + def get_unmatched_p_color(self): + return QColor(self.unmatched_p_color) + + def get_comment_color(self): + """ Return color for the comments """ + return self.formats['comment'].foreground().color() + + def get_color_name(self, fmt): + """Return color name assigned to a given format""" + return self.formats[fmt].foreground().color().name() + + def setup_formats(self, font=None): + base_format = QTextCharFormat() + if font is not None: + self.font = font + if self.font is not None: + base_format.setFont(self.font) + self.sig_font_changed.emit() + self.formats = {} + colors = self.color_scheme.copy() + self.background_color = colors.pop("background") + self.currentline_color = colors.pop("currentline") + self.currentcell_color = colors.pop("currentcell") + self.occurrence_color = colors.pop("occurrence") + self.ctrlclick_color = colors.pop("ctrlclick") + self.sideareas_color = colors.pop("sideareas") + self.matched_p_color = colors.pop("matched_p") + self.unmatched_p_color = colors.pop("unmatched_p") + for name, (color, bold, italic) in list(colors.items()): + format = QTextCharFormat(base_format) + format.setForeground(QColor(color)) + if bold: + format.setFontWeight(QFont.Bold) + format.setFontItalic(italic) + self.formats[name] = format + + def set_color_scheme(self, color_scheme): + if is_text_string(color_scheme): + self.color_scheme = get_color_scheme(color_scheme) + else: + self.color_scheme = color_scheme + + self.setup_formats() + self.rehighlight() + + @staticmethod + def _find_prev_non_blank_block(current_block): + previous_block = (current_block.previous() + if current_block.blockNumber() else None) + # find the previous non-blank block + while (previous_block and previous_block.blockNumber() and + previous_block.text().strip() == ''): + previous_block = previous_block.previous() + return previous_block + + def update_patterns(self, patterns): + """Update patterns to underline.""" + all_patterns = DEFAULT_PATTERNS.copy() + additional_patterns = patterns.copy() + + # Check that default keys are not overwritten + for key in DEFAULT_PATTERNS.keys(): + if key in additional_patterns: + # TODO: print warning or check this at the plugin level? + additional_patterns.pop(key) + all_patterns.update(additional_patterns) + + self.patterns = create_patterns(all_patterns, compile=True) + + def highlightBlock(self, text): + """ + Highlights a block of text. Please do not override, this method. + Instead you should implement + :func:`spyder.utils.syntaxhighplighters.SyntaxHighlighter.highlight_block`. + + :param text: text to highlight. + """ + self.highlight_block(text) + + def highlight_block(self, text): + """ + Abstract method. Override this to apply syntax highlighting. + + :param text: Line of text to highlight. + :param block: current block + """ + raise NotImplementedError() + + def highlight_patterns(self, text, offset=0): + """Highlight URI and mailto: patterns.""" + for match in self.patterns.finditer(text, offset): + for __, value in list(match.groupdict().items()): + if value: + start, end = get_span(match) + start = max([0, start + offset]) + end = max([0, end + offset]) + font = self.format(start) + font.setUnderlineStyle(QTextCharFormat.SingleUnderline) + self.setFormat(start, end - start, font) + + def highlight_spaces(self, text, offset=0): + """ + Make blank space less apparent by setting the foreground alpha. + This only has an effect when 'Show blank space' is turned on. + """ + flags_text = self.document().defaultTextOption().flags() + show_blanks = flags_text & QTextOption.ShowTabsAndSpaces + if show_blanks: + format_leading = self.formats.get("leading", None) + format_trailing = self.formats.get("trailing", None) + text = text[offset:] + for match in self.BLANKPROG.finditer(text): + start, end = get_span(match) + start = max([0, start+offset]) + end = max([0, end+offset]) + # Format trailing spaces at the end of the line. + if end == qstring_length(text) and format_trailing is not None: + self.setFormat(start, end - start, format_trailing) + # Format leading spaces, e.g. indentation. + if start == 0 and format_leading is not None: + self.setFormat(start, end - start, format_leading) + format = self.format(start) + color_foreground = format.foreground().color() + alpha_new = self.BLANK_ALPHA_FACTOR * color_foreground.alphaF() + color_foreground.setAlphaF(alpha_new) + self.setFormat(start, end - start, color_foreground) + + def highlight_extras(self, text, offset=0): + """ + Perform additional global text highlight. + + Derived classes could call this function at the end of + highlight_block(). + """ + self.highlight_spaces(text, offset=offset) + self.highlight_patterns(text, offset=offset) + + def rehighlight(self): + QApplication.setOverrideCursor(QCursor(Qt.WaitCursor)) + QSyntaxHighlighter.rehighlight(self) + QApplication.restoreOverrideCursor() + + +class TextSH(BaseSH): + """Simple Text Syntax Highlighter Class (only highlight spaces).""" + + def highlight_block(self, text): + """Implement highlight, only highlight spaces.""" + text = to_text_string(text) + self.setFormat(0, qstring_length(text), self.formats["normal"]) + self.highlight_extras(text) + + +class GenericSH(BaseSH): + """Generic Syntax Highlighter""" + # Syntax highlighting rules: + PROG = None # to be redefined in child classes + + def highlight_block(self, text): + """Implement highlight using regex defined in children classes.""" + text = to_text_string(text) + self.setFormat(0, qstring_length(text), self.formats["normal"]) + + index = 0 + for match in self.PROG.finditer(text): + for key, value in list(match.groupdict().items()): + if value: + start, end = get_span(match, key) + index += end-start + self.setFormat(start, end-start, self.formats[key]) + + self.highlight_extras(text) + + +#============================================================================== +# Python syntax highlighter +#============================================================================== +def make_python_patterns(additional_keywords=[], additional_builtins=[]): + "Strongly inspired from idlelib.ColorDelegator.make_pat" + kwlist = keyword.kwlist + additional_keywords + builtinlist = [str(name) for name in dir(builtins) + if not name.startswith('_')] + additional_builtins + repeated = set(kwlist) & set(builtinlist) + for repeated_element in repeated: + kwlist.remove(repeated_element) + kw = r"\b" + any("keyword", kwlist) + r"\b" + builtin = r"([^.'\"\\#]\b|^)" + any("builtin", builtinlist) + r"\b" + comment = any("comment", [r"#[^\n]*"]) + instance = any("instance", [r"\bself\b", + r"\bcls\b", + (r"^\s*@([a-zA-Z_][a-zA-Z0-9_]*)" + r"(\.[a-zA-Z_][a-zA-Z0-9_]*)*")]) + number_regex = [r"\b[+-]?[0-9]+[lLjJ]?\b", + r"\b[+-]?0[xX][0-9A-Fa-f]+[lL]?\b", + r"\b[+-]?0[oO][0-7]+[lL]?\b", + r"\b[+-]?0[bB][01]+[lL]?\b", + r"\b[+-]?[0-9]+(?:\.[0-9]+)?(?:[eE][+-]?[0-9]+)?[jJ]?\b"] + if PY3: + prefix = "r|u|R|U|f|F|fr|Fr|fR|FR|rf|rF|Rf|RF|b|B|br|Br|bR|BR|rb|rB|Rb|RB" + else: + prefix = "r|u|ur|R|U|UR|Ur|uR|b|B|br|Br|bR|BR" + sqstring = r"(\b(%s))?'[^'\\\n]*(\\.[^'\\\n]*)*'?" % prefix + dqstring = r'(\b(%s))?"[^"\\\n]*(\\.[^"\\\n]*)*"?' % prefix + uf_sqstring = r"(\b(%s))?'[^'\\\n]*(\\.[^'\\\n]*)*(\\)$(?!')$" % prefix + uf_dqstring = r'(\b(%s))?"[^"\\\n]*(\\.[^"\\\n]*)*(\\)$(?!")$' % prefix + ufe_sqstring = r"(\b(%s))?'[^'\\\n]*(\\.[^'\\\n]*)*(?!\\)$(?!')$" % prefix + ufe_dqstring = r'(\b(%s))?"[^"\\\n]*(\\.[^"\\\n]*)*(?!\\)$(?!")$' % prefix + sq3string = r"(\b(%s))?'''[^'\\]*((\\.|'(?!''))[^'\\]*)*(''')?" % prefix + dq3string = r'(\b(%s))?"""[^"\\]*((\\.|"(?!""))[^"\\]*)*(""")?' % prefix + uf_sq3string = r"(\b(%s))?'''[^'\\]*((\\.|'(?!''))[^'\\]*)*(\\)?(?!''')$" \ + % prefix + uf_dq3string = r'(\b(%s))?"""[^"\\]*((\\.|"(?!""))[^"\\]*)*(\\)?(?!""")$' \ + % prefix + # Needed to achieve correct highlighting in Python 3.6+ + # See spyder-ide/spyder#7324. + if PY36_OR_MORE: + # Based on + # https://github.com/python/cpython/blob/ + # 81950495ba2c36056e0ce48fd37d514816c26747/Lib/tokenize.py#L117 + # In order: Hexnumber, Binnumber, Octnumber, Decnumber, + # Pointfloat + Exponent, Expfloat, Imagnumber + number_regex = [ + r"\b[+-]?0[xX](?:_?[0-9A-Fa-f])+[lL]?\b", + r"\b[+-]?0[bB](?:_?[01])+[lL]?\b", + r"\b[+-]?0[oO](?:_?[0-7])+[lL]?\b", + r"\b[+-]?(?:0(?:_?0)*|[1-9](?:_?[0-9])*)[lL]?\b", + r"\b((\.[0-9](?:_?[0-9])*')|\.[0-9](?:_?[0-9])*)" + "([eE][+-]?[0-9](?:_?[0-9])*)?[jJ]?\b", + r"\b[0-9](?:_?[0-9])*([eE][+-]?[0-9](?:_?[0-9])*)?[jJ]?\b", + r"\b[0-9](?:_?[0-9])*[jJ]\b"] + number = any("number", number_regex) + + string = any("string", [sq3string, dq3string, sqstring, dqstring]) + ufstring1 = any("uf_sqstring", [uf_sqstring]) + ufstring2 = any("uf_dqstring", [uf_dqstring]) + ufstring3 = any("uf_sq3string", [uf_sq3string]) + ufstring4 = any("uf_dq3string", [uf_dq3string]) + ufstring5 = any("ufe_sqstring", [ufe_sqstring]) + ufstring6 = any("ufe_dqstring", [ufe_dqstring]) + return "|".join([instance, kw, builtin, comment, + ufstring1, ufstring2, ufstring3, ufstring4, ufstring5, + ufstring6, string, number, any("SYNC", [r"\n"])]) + + +def make_ipython_patterns(additional_keywords=[], additional_builtins=[]): + return (make_python_patterns(additional_keywords, additional_builtins) + + r"|^\s*%%?(?P[^\s]*)") + + +def get_code_cell_name(text): + """Returns a code cell name from a code cell comment.""" + name = text.strip().lstrip("#% ") + if name.startswith(""): + name = name[10:].lstrip() + elif name.startswith("In["): + name = name[2:] + if name.endswith("]:"): + name = name[:-1] + name = name.strip() + return name + + +class PythonSH(BaseSH): + """Python Syntax Highlighter""" + # Syntax highlighting rules: + add_kw = ['async', 'await'] + PROG = re.compile(make_python_patterns(additional_keywords=add_kw), re.S) + IDPROG = re.compile(r"\s+(\w+)", re.S) + ASPROG = re.compile(r"\b(as)\b") + # Syntax highlighting states (from one text block to another): + (NORMAL, INSIDE_SQ3STRING, INSIDE_DQ3STRING, + INSIDE_SQSTRING, INSIDE_DQSTRING, + INSIDE_NON_MULTILINE_STRING) = list(range(6)) + DEF_TYPES = {"def": OutlineExplorerData.FUNCTION, + "class": OutlineExplorerData.CLASS} + # Comments suitable for Outline Explorer + OECOMMENT = re.compile(r'^(# ?--[-]+|##[#]+ )[ -]*[^- ]+') + + def __init__(self, parent, font=None, color_scheme='Spyder'): + BaseSH.__init__(self, parent, font, color_scheme) + self.cell_separators = CELL_LANGUAGES['Python'] + # Avoid updating the outline explorer with every single letter typed + self.outline_explorer_data_update_timer = QTimer() + self.outline_explorer_data_update_timer.setSingleShot(True) + + def highlight_match(self, text, match, key, value, offset, + state, import_stmt, oedata): + """Highlight a single match.""" + start, end = get_span(match, key) + start = max([0, start+offset]) + end = max([0, end+offset]) + length = end - start + if key == "uf_sq3string": + self.setFormat(start, length, self.formats["string"]) + state = self.INSIDE_SQ3STRING + elif key == "uf_dq3string": + self.setFormat(start, length, self.formats["string"]) + state = self.INSIDE_DQ3STRING + elif key == "uf_sqstring": + self.setFormat(start, length, self.formats["string"]) + state = self.INSIDE_SQSTRING + elif key == "uf_dqstring": + self.setFormat(start, length, self.formats["string"]) + state = self.INSIDE_DQSTRING + elif key in ["ufe_sqstring", "ufe_dqstring"]: + self.setFormat(start, length, self.formats["string"]) + state = self.INSIDE_NON_MULTILINE_STRING + else: + self.setFormat(start, length, self.formats[key]) + if key == "comment": + if text.lstrip().startswith(self.cell_separators): + oedata = OutlineExplorerData(self.currentBlock()) + oedata.text = to_text_string(text).strip() + # cell_head: string containing the first group + # of '%'s in the cell header + cell_head = re.search(r"%+|$", text.lstrip()).group() + if cell_head == '': + oedata.cell_level = 0 + else: + oedata.cell_level = qstring_length(cell_head) - 2 + oedata.fold_level = start + oedata.def_type = OutlineExplorerData.CELL + def_name = get_code_cell_name(text) + oedata.def_name = def_name + # Keep list of cells for performence reasons + self._cell_list.append(oedata) + elif self.OECOMMENT.match(text.lstrip()): + oedata = OutlineExplorerData(self.currentBlock()) + oedata.text = to_text_string(text).strip() + oedata.fold_level = start + oedata.def_type = OutlineExplorerData.COMMENT + oedata.def_name = text.strip() + elif key == "keyword": + if value in ("def", "class"): + match1 = self.IDPROG.match(text, end) + if match1: + start1, end1 = get_span(match1, 1) + self.setFormat(start1, end1-start1, + self.formats["definition"]) + oedata = OutlineExplorerData(self.currentBlock()) + oedata.text = to_text_string(text) + oedata.fold_level = (qstring_length(text) + - qstring_length(text.lstrip())) + oedata.def_type = self.DEF_TYPES[to_text_string(value)] + oedata.def_name = text[start1:end1] + oedata.color = self.formats["definition"] + elif value in ("elif", "else", "except", "finally", + "for", "if", "try", "while", + "with"): + if text.lstrip().startswith(value): + oedata = OutlineExplorerData(self.currentBlock()) + oedata.text = to_text_string(text).strip() + oedata.fold_level = start + oedata.def_type = OutlineExplorerData.STATEMENT + oedata.def_name = text.strip() + elif value == "import": + import_stmt = text.strip() + # color all the "as" words on same line, except + # if in a comment; cheap approximation to the + # truth + if '#' in text: + endpos = qstring_length(text[:text.index('#')]) + else: + endpos = qstring_length(text) + while True: + match1 = self.ASPROG.match(text, end, endpos) + if not match1: + break + start, end = get_span(match1, 1) + self.setFormat(start, length, self.formats["keyword"]) + + return state, import_stmt, oedata + + def highlight_block(self, text): + """Implement specific highlight for Python.""" + text = to_text_string(text) + prev_state = tbh.get_state(self.currentBlock().previous()) + if prev_state == self.INSIDE_DQ3STRING: + offset = -4 + text = r'""" '+text + elif prev_state == self.INSIDE_SQ3STRING: + offset = -4 + text = r"''' "+text + elif prev_state == self.INSIDE_DQSTRING: + offset = -2 + text = r'" '+text + elif prev_state == self.INSIDE_SQSTRING: + offset = -2 + text = r"' "+text + else: + offset = 0 + prev_state = self.NORMAL + + oedata = None + import_stmt = None + + self.setFormat(0, qstring_length(text), self.formats["normal"]) + + state = self.NORMAL + for match in self.PROG.finditer(text): + for key, value in list(match.groupdict().items()): + if value: + state, import_stmt, oedata = self.highlight_match( + text, match, key, value, offset, + state, import_stmt, oedata) + + tbh.set_state(self.currentBlock(), state) + + # Use normal format for indentation and trailing spaces + # Unless we are in a string + states_multiline_string = [ + self.INSIDE_DQ3STRING, self.INSIDE_SQ3STRING, + self.INSIDE_DQSTRING, self.INSIDE_SQSTRING] + states_string = states_multiline_string + [ + self.INSIDE_NON_MULTILINE_STRING] + self.formats['leading'] = self.formats['normal'] + if prev_state in states_multiline_string: + self.formats['leading'] = self.formats["string"] + self.formats['trailing'] = self.formats['normal'] + if state in states_string: + self.formats['trailing'] = self.formats['string'] + self.highlight_extras(text, offset) + + block = self.currentBlock() + data = block.userData() + + need_data = (oedata or import_stmt) + + if need_data and not data: + data = BlockUserData(self.editor) + + # Try updating + update = False + if oedata and data and data.oedata: + update = data.oedata.update(oedata) + + if data and not update: + data.oedata = oedata + self.outline_explorer_data_update_timer.start(500) + + if (import_stmt) or (data and data.import_statement): + data.import_statement = import_stmt + + block.setUserData(data) + + def get_import_statements(self): + """Get import statment list.""" + block = self.document().firstBlock() + statments = [] + while block.isValid(): + data = block.userData() + if data and data.import_statement: + statments.append(data.import_statement) + block = block.next() + return statments + + def rehighlight(self): + BaseSH.rehighlight(self) + + +# ============================================================================= +# IPython syntax highlighter +# ============================================================================= +class IPythonSH(PythonSH): + """IPython Syntax Highlighter""" + add_kw = ['async', 'await'] + PROG = re.compile(make_ipython_patterns(additional_keywords=add_kw), re.S) + + +#============================================================================== +# Cython syntax highlighter +#============================================================================== +C_TYPES = 'bool char double enum float int long mutable short signed struct unsigned void NULL' + +class CythonSH(PythonSH): + """Cython Syntax Highlighter""" + ADDITIONAL_KEYWORDS = [ + "cdef", "ctypedef", "cpdef", "inline", "cimport", "extern", + "include", "begin", "end", "by", "gil", "nogil", "const", "public", + "readonly", "fused", "static", "api", "DEF", "IF", "ELIF", "ELSE"] + + ADDITIONAL_BUILTINS = C_TYPES.split() + [ + "array", "bint", "Py_ssize_t", "intern", "reload", "sizeof", "NULL"] + PROG = re.compile(make_python_patterns(ADDITIONAL_KEYWORDS, + ADDITIONAL_BUILTINS), re.S) + IDPROG = re.compile(r"\s+([\w\.]+)", re.S) + + +#============================================================================== +# Enaml syntax highlighter +#============================================================================== +class EnamlSH(PythonSH): + """Enaml Syntax Highlighter""" + ADDITIONAL_KEYWORDS = ["enamldef", "template", "attr", "event", "const", "alias", + "func"] + ADDITIONAL_BUILTINS = [] + PROG = re.compile(make_python_patterns(ADDITIONAL_KEYWORDS, + ADDITIONAL_BUILTINS), re.S) + IDPROG = re.compile(r"\s+([\w\.]+)", re.S) + + +#============================================================================== +# C/C++ syntax highlighter +#============================================================================== +C_KEYWORDS1 = 'and and_eq bitand bitor break case catch const const_cast continue default delete do dynamic_cast else explicit export extern for friend goto if inline namespace new not not_eq operator or or_eq private protected public register reinterpret_cast return sizeof static static_cast switch template throw try typedef typeid typename union using virtual while xor xor_eq' +C_KEYWORDS2 = 'a addindex addtogroup anchor arg attention author b brief bug c class code date def defgroup deprecated dontinclude e em endcode endhtmlonly ifdef endif endlatexonly endlink endverbatim enum example exception f$ file fn hideinitializer htmlinclude htmlonly if image include ingroup internal invariant interface latexonly li line link mainpage name namespace nosubgrouping note overload p page par param post pre ref relates remarks return retval sa section see showinitializer since skip skipline subsection test throw todo typedef union until var verbatim verbinclude version warning weakgroup' +C_KEYWORDS3 = 'asm auto class compl false true volatile wchar_t' + +def make_generic_c_patterns(keywords, builtins, + instance=None, define=None, comment=None): + "Strongly inspired from idlelib.ColorDelegator.make_pat" + kw = r"\b" + any("keyword", keywords.split()) + r"\b" + builtin = r"\b" + any("builtin", builtins.split()+C_TYPES.split()) + r"\b" + if comment is None: + comment = any("comment", [r"//[^\n]*", r"\/\*(.*?)\*\/"]) + comment_start = any("comment_start", [r"\/\*"]) + comment_end = any("comment_end", [r"\*\/"]) + if instance is None: + instance = any("instance", [r"\bthis\b"]) + number = any("number", + [r"\b[+-]?[0-9]+[lL]?\b", + r"\b[+-]?0[xX][0-9A-Fa-f]+[lL]?\b", + r"\b[+-]?[0-9]+(?:\.[0-9]+)?(?:[eE][+-]?[0-9]+)?\b"]) + sqstring = r"(\b[rRuU])?'[^'\\\n]*(\\.[^'\\\n]*)*'?" + dqstring = r'(\b[rRuU])?"[^"\\\n]*(\\.[^"\\\n]*)*"?' + string = any("string", [sqstring, dqstring]) + if define is None: + define = any("define", [r"#[^\n]*"]) + return "|".join([instance, kw, comment, string, number, + comment_start, comment_end, builtin, + define, any("SYNC", [r"\n"])]) + +def make_cpp_patterns(): + return make_generic_c_patterns(C_KEYWORDS1+' '+C_KEYWORDS2, C_KEYWORDS3) + +class CppSH(BaseSH): + """C/C++ Syntax Highlighter""" + # Syntax highlighting rules: + PROG = re.compile(make_cpp_patterns(), re.S) + # Syntax highlighting states (from one text block to another): + NORMAL = 0 + INSIDE_COMMENT = 1 + def __init__(self, parent, font=None, color_scheme=None): + BaseSH.__init__(self, parent, font, color_scheme) + + def highlight_block(self, text): + """Implement highlight specific for C/C++.""" + text = to_text_string(text) + inside_comment = tbh.get_state(self.currentBlock().previous()) == self.INSIDE_COMMENT + self.setFormat(0, qstring_length(text), + self.formats["comment" if inside_comment else "normal"]) + + index = 0 + for match in self.PROG.finditer(text): + for key, value in list(match.groupdict().items()): + if value: + start, end = get_span(match, key) + index += end-start + if key == "comment_start": + inside_comment = True + self.setFormat(start, qstring_length(text)-start, + self.formats["comment"]) + elif key == "comment_end": + inside_comment = False + self.setFormat(start, end-start, + self.formats["comment"]) + elif inside_comment: + self.setFormat(start, end-start, + self.formats["comment"]) + elif key == "define": + self.setFormat(start, end-start, + self.formats["number"]) + else: + self.setFormat(start, end-start, self.formats[key]) + + self.highlight_extras(text) + + last_state = self.INSIDE_COMMENT if inside_comment else self.NORMAL + tbh.set_state(self.currentBlock(), last_state) + + +def make_opencl_patterns(): + # Keywords: + kwstr1 = 'cl_char cl_uchar cl_short cl_ushort cl_int cl_uint cl_long cl_ulong cl_half cl_float cl_double cl_platform_id cl_device_id cl_context cl_command_queue cl_mem cl_program cl_kernel cl_event cl_sampler cl_bool cl_bitfield cl_device_type cl_platform_info cl_device_info cl_device_address_info cl_device_fp_config cl_device_mem_cache_type cl_device_local_mem_type cl_device_exec_capabilities cl_command_queue_properties cl_context_properties cl_context_info cl_command_queue_info cl_channel_order cl_channel_type cl_mem_flags cl_mem_object_type cl_mem_info cl_image_info cl_addressing_mode cl_filter_mode cl_sampler_info cl_map_flags cl_program_info cl_program_build_info cl_build_status cl_kernel_info cl_kernel_work_group_info cl_event_info cl_command_type cl_profiling_info cl_image_format' + # Constants: + kwstr2 = 'CL_FALSE, CL_TRUE, CL_PLATFORM_PROFILE, CL_PLATFORM_VERSION, CL_PLATFORM_NAME, CL_PLATFORM_VENDOR, CL_PLATFORM_EXTENSIONS, CL_DEVICE_TYPE_DEFAULT , CL_DEVICE_TYPE_CPU, CL_DEVICE_TYPE_GPU, CL_DEVICE_TYPE_ACCELERATOR, CL_DEVICE_TYPE_ALL, CL_DEVICE_TYPE, CL_DEVICE_VENDOR_ID, CL_DEVICE_MAX_COMPUTE_UNITS, CL_DEVICE_MAX_WORK_ITEM_DIMENSIONS, CL_DEVICE_MAX_WORK_GROUP_SIZE, CL_DEVICE_MAX_WORK_ITEM_SIZES, CL_DEVICE_PREFERRED_VECTOR_WIDTH_CHAR, CL_DEVICE_PREFERRED_VECTOR_WIDTH_SHORT, CL_DEVICE_PREFERRED_VECTOR_WIDTH_INT, CL_DEVICE_PREFERRED_VECTOR_WIDTH_LONG, CL_DEVICE_PREFERRED_VECTOR_WIDTH_FLOAT, CL_DEVICE_PREFERRED_VECTOR_WIDTH_DOUBLE, CL_DEVICE_MAX_CLOCK_FREQUENCY, CL_DEVICE_ADDRESS_BITS, CL_DEVICE_MAX_READ_IMAGE_ARGS, CL_DEVICE_MAX_WRITE_IMAGE_ARGS, CL_DEVICE_MAX_MEM_ALLOC_SIZE, CL_DEVICE_IMAGE2D_MAX_WIDTH, CL_DEVICE_IMAGE2D_MAX_HEIGHT, CL_DEVICE_IMAGE3D_MAX_WIDTH, CL_DEVICE_IMAGE3D_MAX_HEIGHT, CL_DEVICE_IMAGE3D_MAX_DEPTH, CL_DEVICE_IMAGE_SUPPORT, CL_DEVICE_MAX_PARAMETER_SIZE, CL_DEVICE_MAX_SAMPLERS, CL_DEVICE_MEM_BASE_ADDR_ALIGN, CL_DEVICE_MIN_DATA_TYPE_ALIGN_SIZE, CL_DEVICE_SINGLE_FP_CONFIG, CL_DEVICE_GLOBAL_MEM_CACHE_TYPE, CL_DEVICE_GLOBAL_MEM_CACHELINE_SIZE, CL_DEVICE_GLOBAL_MEM_CACHE_SIZE, CL_DEVICE_GLOBAL_MEM_SIZE, CL_DEVICE_MAX_CONSTANT_BUFFER_SIZE, CL_DEVICE_MAX_CONSTANT_ARGS, CL_DEVICE_LOCAL_MEM_TYPE, CL_DEVICE_LOCAL_MEM_SIZE, CL_DEVICE_ERROR_CORRECTION_SUPPORT, CL_DEVICE_PROFILING_TIMER_RESOLUTION, CL_DEVICE_ENDIAN_LITTLE, CL_DEVICE_AVAILABLE, CL_DEVICE_COMPILER_AVAILABLE, CL_DEVICE_EXECUTION_CAPABILITIES, CL_DEVICE_QUEUE_PROPERTIES, CL_DEVICE_NAME, CL_DEVICE_VENDOR, CL_DRIVER_VERSION, CL_DEVICE_PROFILE, CL_DEVICE_VERSION, CL_DEVICE_EXTENSIONS, CL_DEVICE_PLATFORM, CL_FP_DENORM, CL_FP_INF_NAN, CL_FP_ROUND_TO_NEAREST, CL_FP_ROUND_TO_ZERO, CL_FP_ROUND_TO_INF, CL_FP_FMA, CL_NONE, CL_READ_ONLY_CACHE, CL_READ_WRITE_CACHE, CL_LOCAL, CL_GLOBAL, CL_EXEC_KERNEL, CL_EXEC_NATIVE_KERNEL, CL_QUEUE_OUT_OF_ORDER_EXEC_MODE_ENABLE, CL_QUEUE_PROFILING_ENABLE, CL_CONTEXT_REFERENCE_COUNT, CL_CONTEXT_DEVICES, CL_CONTEXT_PROPERTIES, CL_CONTEXT_PLATFORM, CL_QUEUE_CONTEXT, CL_QUEUE_DEVICE, CL_QUEUE_REFERENCE_COUNT, CL_QUEUE_PROPERTIES, CL_MEM_READ_WRITE, CL_MEM_WRITE_ONLY, CL_MEM_READ_ONLY, CL_MEM_USE_HOST_PTR, CL_MEM_ALLOC_HOST_PTR, CL_MEM_COPY_HOST_PTR, CL_R, CL_A, CL_RG, CL_RA, CL_RGB, CL_RGBA, CL_BGRA, CL_ARGB, CL_INTENSITY, CL_LUMINANCE, CL_SNORM_INT8, CL_SNORM_INT16, CL_UNORM_INT8, CL_UNORM_INT16, CL_UNORM_SHORT_565, CL_UNORM_SHORT_555, CL_UNORM_INT_101010, CL_SIGNED_INT8, CL_SIGNED_INT16, CL_SIGNED_INT32, CL_UNSIGNED_INT8, CL_UNSIGNED_INT16, CL_UNSIGNED_INT32, CL_HALF_FLOAT, CL_FLOAT, CL_MEM_OBJECT_BUFFER, CL_MEM_OBJECT_IMAGE2D, CL_MEM_OBJECT_IMAGE3D, CL_MEM_TYPE, CL_MEM_FLAGS, CL_MEM_SIZECL_MEM_HOST_PTR, CL_MEM_HOST_PTR, CL_MEM_MAP_COUNT, CL_MEM_REFERENCE_COUNT, CL_MEM_CONTEXT, CL_IMAGE_FORMAT, CL_IMAGE_ELEMENT_SIZE, CL_IMAGE_ROW_PITCH, CL_IMAGE_SLICE_PITCH, CL_IMAGE_WIDTH, CL_IMAGE_HEIGHT, CL_IMAGE_DEPTH, CL_ADDRESS_NONE, CL_ADDRESS_CLAMP_TO_EDGE, CL_ADDRESS_CLAMP, CL_ADDRESS_REPEAT, CL_FILTER_NEAREST, CL_FILTER_LINEAR, CL_SAMPLER_REFERENCE_COUNT, CL_SAMPLER_CONTEXT, CL_SAMPLER_NORMALIZED_COORDS, CL_SAMPLER_ADDRESSING_MODE, CL_SAMPLER_FILTER_MODE, CL_MAP_READ, CL_MAP_WRITE, CL_PROGRAM_REFERENCE_COUNT, CL_PROGRAM_CONTEXT, CL_PROGRAM_NUM_DEVICES, CL_PROGRAM_DEVICES, CL_PROGRAM_SOURCE, CL_PROGRAM_BINARY_SIZES, CL_PROGRAM_BINARIES, CL_PROGRAM_BUILD_STATUS, CL_PROGRAM_BUILD_OPTIONS, CL_PROGRAM_BUILD_LOG, CL_BUILD_SUCCESS, CL_BUILD_NONE, CL_BUILD_ERROR, CL_BUILD_IN_PROGRESS, CL_KERNEL_FUNCTION_NAME, CL_KERNEL_NUM_ARGS, CL_KERNEL_REFERENCE_COUNT, CL_KERNEL_CONTEXT, CL_KERNEL_PROGRAM, CL_KERNEL_WORK_GROUP_SIZE, CL_KERNEL_COMPILE_WORK_GROUP_SIZE, CL_KERNEL_LOCAL_MEM_SIZE, CL_EVENT_COMMAND_QUEUE, CL_EVENT_COMMAND_TYPE, CL_EVENT_REFERENCE_COUNT, CL_EVENT_COMMAND_EXECUTION_STATUS, CL_COMMAND_NDRANGE_KERNEL, CL_COMMAND_TASK, CL_COMMAND_NATIVE_KERNEL, CL_COMMAND_READ_BUFFER, CL_COMMAND_WRITE_BUFFER, CL_COMMAND_COPY_BUFFER, CL_COMMAND_READ_IMAGE, CL_COMMAND_WRITE_IMAGE, CL_COMMAND_COPY_IMAGE, CL_COMMAND_COPY_IMAGE_TO_BUFFER, CL_COMMAND_COPY_BUFFER_TO_IMAGE, CL_COMMAND_MAP_BUFFER, CL_COMMAND_MAP_IMAGE, CL_COMMAND_UNMAP_MEM_OBJECT, CL_COMMAND_MARKER, CL_COMMAND_ACQUIRE_GL_OBJECTS, CL_COMMAND_RELEASE_GL_OBJECTS, command execution status, CL_COMPLETE, CL_RUNNING, CL_SUBMITTED, CL_QUEUED, CL_PROFILING_COMMAND_QUEUED, CL_PROFILING_COMMAND_SUBMIT, CL_PROFILING_COMMAND_START, CL_PROFILING_COMMAND_END, CL_CHAR_BIT, CL_SCHAR_MAX, CL_SCHAR_MIN, CL_CHAR_MAX, CL_CHAR_MIN, CL_UCHAR_MAX, CL_SHRT_MAX, CL_SHRT_MIN, CL_USHRT_MAX, CL_INT_MAX, CL_INT_MIN, CL_UINT_MAX, CL_LONG_MAX, CL_LONG_MIN, CL_ULONG_MAX, CL_FLT_DIG, CL_FLT_MANT_DIG, CL_FLT_MAX_10_EXP, CL_FLT_MAX_EXP, CL_FLT_MIN_10_EXP, CL_FLT_MIN_EXP, CL_FLT_RADIX, CL_FLT_MAX, CL_FLT_MIN, CL_FLT_EPSILON, CL_DBL_DIG, CL_DBL_MANT_DIG, CL_DBL_MAX_10_EXP, CL_DBL_MAX_EXP, CL_DBL_MIN_10_EXP, CL_DBL_MIN_EXP, CL_DBL_RADIX, CL_DBL_MAX, CL_DBL_MIN, CL_DBL_EPSILON, CL_SUCCESS, CL_DEVICE_NOT_FOUND, CL_DEVICE_NOT_AVAILABLE, CL_COMPILER_NOT_AVAILABLE, CL_MEM_OBJECT_ALLOCATION_FAILURE, CL_OUT_OF_RESOURCES, CL_OUT_OF_HOST_MEMORY, CL_PROFILING_INFO_NOT_AVAILABLE, CL_MEM_COPY_OVERLAP, CL_IMAGE_FORMAT_MISMATCH, CL_IMAGE_FORMAT_NOT_SUPPORTED, CL_BUILD_PROGRAM_FAILURE, CL_MAP_FAILURE, CL_INVALID_VALUE, CL_INVALID_DEVICE_TYPE, CL_INVALID_PLATFORM, CL_INVALID_DEVICE, CL_INVALID_CONTEXT, CL_INVALID_QUEUE_PROPERTIES, CL_INVALID_COMMAND_QUEUE, CL_INVALID_HOST_PTR, CL_INVALID_MEM_OBJECT, CL_INVALID_IMAGE_FORMAT_DESCRIPTOR, CL_INVALID_IMAGE_SIZE, CL_INVALID_SAMPLER, CL_INVALID_BINARY, CL_INVALID_BUILD_OPTIONS, CL_INVALID_PROGRAM, CL_INVALID_PROGRAM_EXECUTABLE, CL_INVALID_KERNEL_NAME, CL_INVALID_KERNEL_DEFINITION, CL_INVALID_KERNEL, CL_INVALID_ARG_INDEX, CL_INVALID_ARG_VALUE, CL_INVALID_ARG_SIZE, CL_INVALID_KERNEL_ARGS, CL_INVALID_WORK_DIMENSION, CL_INVALID_WORK_GROUP_SIZE, CL_INVALID_WORK_ITEM_SIZE, CL_INVALID_GLOBAL_OFFSET, CL_INVALID_EVENT_WAIT_LIST, CL_INVALID_EVENT, CL_INVALID_OPERATION, CL_INVALID_GL_OBJECT, CL_INVALID_BUFFER_SIZE, CL_INVALID_MIP_LEVEL, CL_INVALID_GLOBAL_WORK_SIZE' + # Functions: + builtins = 'clGetPlatformIDs, clGetPlatformInfo, clGetDeviceIDs, clGetDeviceInfo, clCreateContext, clCreateContextFromType, clReleaseContext, clGetContextInfo, clCreateCommandQueue, clRetainCommandQueue, clReleaseCommandQueue, clGetCommandQueueInfo, clSetCommandQueueProperty, clCreateBuffer, clCreateImage2D, clCreateImage3D, clRetainMemObject, clReleaseMemObject, clGetSupportedImageFormats, clGetMemObjectInfo, clGetImageInfo, clCreateSampler, clRetainSampler, clReleaseSampler, clGetSamplerInfo, clCreateProgramWithSource, clCreateProgramWithBinary, clRetainProgram, clReleaseProgram, clBuildProgram, clUnloadCompiler, clGetProgramInfo, clGetProgramBuildInfo, clCreateKernel, clCreateKernelsInProgram, clRetainKernel, clReleaseKernel, clSetKernelArg, clGetKernelInfo, clGetKernelWorkGroupInfo, clWaitForEvents, clGetEventInfo, clRetainEvent, clReleaseEvent, clGetEventProfilingInfo, clFlush, clFinish, clEnqueueReadBuffer, clEnqueueWriteBuffer, clEnqueueCopyBuffer, clEnqueueReadImage, clEnqueueWriteImage, clEnqueueCopyImage, clEnqueueCopyImageToBuffer, clEnqueueCopyBufferToImage, clEnqueueMapBuffer, clEnqueueMapImage, clEnqueueUnmapMemObject, clEnqueueNDRangeKernel, clEnqueueTask, clEnqueueNativeKernel, clEnqueueMarker, clEnqueueWaitForEvents, clEnqueueBarrier' + # Qualifiers: + qualifiers = '__global __local __constant __private __kernel' + keyword_list = C_KEYWORDS1+' '+C_KEYWORDS2+' '+kwstr1+' '+kwstr2 + builtin_list = C_KEYWORDS3+' '+builtins+' '+qualifiers + return make_generic_c_patterns(keyword_list, builtin_list) + +class OpenCLSH(CppSH): + """OpenCL Syntax Highlighter""" + PROG = re.compile(make_opencl_patterns(), re.S) + + +#============================================================================== +# Fortran Syntax Highlighter +#============================================================================== +def make_fortran_patterns(): + "Strongly inspired from idlelib.ColorDelegator.make_pat" + kwstr = 'access action advance allocatable allocate apostrophe assign assignment associate asynchronous backspace bind blank blockdata call case character class close common complex contains continue cycle data deallocate decimal delim default dimension direct do dowhile double doubleprecision else elseif elsewhere encoding end endassociate endblockdata enddo endfile endforall endfunction endif endinterface endmodule endprogram endselect endsubroutine endtype endwhere entry eor equivalence err errmsg exist exit external file flush fmt forall form format formatted function go goto id if implicit in include inout integer inquire intent interface intrinsic iomsg iolength iostat kind len logical module name named namelist nextrec nml none nullify number only open opened operator optional out pad parameter pass pause pending pointer pos position precision print private program protected public quote read readwrite real rec recl recursive result return rewind save select selectcase selecttype sequential sign size stat status stop stream subroutine target then to type unformatted unit use value volatile wait where while write' + bistr1 = 'abs achar acos acosd adjustl adjustr aimag aimax0 aimin0 aint ajmax0 ajmin0 akmax0 akmin0 all allocated alog alog10 amax0 amax1 amin0 amin1 amod anint any asin asind associated atan atan2 atan2d atand bitest bitl bitlr bitrl bjtest bit_size bktest break btest cabs ccos cdabs cdcos cdexp cdlog cdsin cdsqrt ceiling cexp char clog cmplx conjg cos cosd cosh count cpu_time cshift csin csqrt dabs dacos dacosd dasin dasind datan datan2 datan2d datand date date_and_time dble dcmplx dconjg dcos dcosd dcosh dcotan ddim dexp dfloat dflotk dfloti dflotj digits dim dimag dint dlog dlog10 dmax1 dmin1 dmod dnint dot_product dprod dreal dsign dsin dsind dsinh dsqrt dtan dtand dtanh eoshift epsilon errsns exp exponent float floati floatj floatk floor fraction free huge iabs iachar iand ibclr ibits ibset ichar idate idim idint idnint ieor ifix iiabs iiand iibclr iibits iibset iidim iidint iidnnt iieor iifix iint iior iiqint iiqnnt iishft iishftc iisign ilen imax0 imax1 imin0 imin1 imod index inint inot int int1 int2 int4 int8 iqint iqnint ior ishft ishftc isign isnan izext jiand jibclr jibits jibset jidim jidint jidnnt jieor jifix jint jior jiqint jiqnnt jishft jishftc jisign jmax0 jmax1 jmin0 jmin1 jmod jnint jnot jzext kiabs kiand kibclr kibits kibset kidim kidint kidnnt kieor kifix kind kint kior kishft kishftc kisign kmax0 kmax1 kmin0 kmin1 kmod knint knot kzext lbound leadz len len_trim lenlge lge lgt lle llt log log10 logical lshift malloc matmul max max0 max1 maxexponent maxloc maxval merge min min0 min1 minexponent minloc minval mod modulo mvbits nearest nint not nworkers number_of_processors pack popcnt poppar precision present product radix random random_number random_seed range real repeat reshape rrspacing rshift scale scan secnds selected_int_kind selected_real_kind set_exponent shape sign sin sind sinh size sizeof sngl snglq spacing spread sqrt sum system_clock tan tand tanh tiny transfer transpose trim ubound unpack verify' + bistr2 = 'cdabs cdcos cdexp cdlog cdsin cdsqrt cotan cotand dcmplx dconjg dcotan dcotand decode dimag dll_export dll_import doublecomplex dreal dvchk encode find flen flush getarg getcharqq getcl getdat getenv gettim hfix ibchng identifier imag int1 int2 int4 intc intrup invalop iostat_msg isha ishc ishl jfix lacfar locking locnear map nargs nbreak ndperr ndpexc offset ovefl peekcharqq precfill prompt qabs qacos qacosd qasin qasind qatan qatand qatan2 qcmplx qconjg qcos qcosd qcosh qdim qexp qext qextd qfloat qimag qlog qlog10 qmax1 qmin1 qmod qreal qsign qsin qsind qsinh qsqrt qtan qtand qtanh ran rand randu rewrite segment setdat settim system timer undfl unlock union val virtual volatile zabs zcos zexp zlog zsin zsqrt' + kw = r"\b" + any("keyword", kwstr.split()) + r"\b" + builtin = r"\b" + any("builtin", bistr1.split()+bistr2.split()) + r"\b" + comment = any("comment", [r"\![^\n]*"]) + number = any("number", + [r"\b[+-]?[0-9]+[lL]?\b", + r"\b[+-]?0[xX][0-9A-Fa-f]+[lL]?\b", + r"\b[+-]?[0-9]+(?:\.[0-9]+)?(?:[eE][+-]?[0-9]+)?\b"]) + sqstring = r"(\b[rRuU])?'[^'\\\n]*(\\.[^'\\\n]*)*'?" + dqstring = r'(\b[rRuU])?"[^"\\\n]*(\\.[^"\\\n]*)*"?' + string = any("string", [sqstring, dqstring]) + return "|".join([kw, comment, string, number, builtin, + any("SYNC", [r"\n"])]) + +class FortranSH(BaseSH): + """Fortran Syntax Highlighter""" + # Syntax highlighting rules: + PROG = re.compile(make_fortran_patterns(), re.S|re.I) + IDPROG = re.compile(r"\s+(\w+)", re.S) + # Syntax highlighting states (from one text block to another): + NORMAL = 0 + def __init__(self, parent, font=None, color_scheme=None): + BaseSH.__init__(self, parent, font, color_scheme) + + def highlight_block(self, text): + """Implement highlight specific for Fortran.""" + text = to_text_string(text) + self.setFormat(0, qstring_length(text), self.formats["normal"]) + + index = 0 + for match in self.PROG.finditer(text): + for key, value in list(match.groupdict().items()): + if value: + start, end = get_span(match, key) + index += end-start + self.setFormat(start, end-start, self.formats[key]) + if value.lower() in ("subroutine", "module", "function"): + match1 = self.IDPROG.match(text, end) + if match1: + start1, end1 = get_span(match1, 1) + self.setFormat(start1, end1-start1, + self.formats["definition"]) + + self.highlight_extras(text) + +class Fortran77SH(FortranSH): + """Fortran 77 Syntax Highlighter""" + def highlight_block(self, text): + """Implement highlight specific for Fortran77.""" + text = to_text_string(text) + if text.startswith(("c", "C")): + self.setFormat(0, qstring_length(text), self.formats["comment"]) + self.highlight_extras(text) + else: + FortranSH.highlight_block(self, text) + self.setFormat(0, 5, self.formats["comment"]) + self.setFormat(73, max([73, qstring_length(text)]), + self.formats["comment"]) + + +#============================================================================== +# IDL highlighter +# +# Contribution from Stuart Mumford (Littlemumford) - 2012-02-02 +# See spyder-ide/spyder#850. +#============================================================================== +def make_idl_patterns(): + """Strongly inspired by idlelib.ColorDelegator.make_pat.""" + kwstr = 'begin of pro function endfor endif endwhile endrep endcase endswitch end if then else for do while repeat until break case switch common continue exit return goto help message print read retall stop' + bistr1 = 'a_correlate abs acos adapt_hist_equal alog alog10 amoeba arg_present arra_equal array_indices ascii_template asin assoc atan beseli beselj besel k besely beta bilinear bin_date binary_template dinfgen dinomial blk_con broyden bytarr byte bytscl c_correlate call_external call_function ceil chebyshev check_math chisqr_cvf chisqr_pdf choldc cholsol cindgen clust_wts cluster color_quan colormap_applicable comfit complex complexarr complexround compute_mesh_normals cond congrid conj convert_coord convol coord2to3 correlate cos cosh cramer create_struct crossp crvlength ct_luminance cti_test curvefit cv_coord cvttobm cw_animate cw_arcball cw_bgroup cw_clr_index cw_colorsel cw_defroi cw_field cw_filesel cw_form cw_fslider cw_light_editor cw_orient cw_palette_editor cw_pdmenu cw_rgbslider cw_tmpl cw_zoom dblarr dcindgen dcomplexarr defroi deriv derivsig determ diag_matrix dialog_message dialog_pickfile pialog_printersetup dialog_printjob dialog_read_image dialog_write_image digital_filter dilate dindgen dist double eigenql eigenvec elmhes eof erode erf erfc erfcx execute exp expand_path expint extrac extract_slice f_cvf f_pdf factorial fft file_basename file_dirname file_expand_path file_info file_same file_search file_test file_which filepath findfile findgen finite fix float floor fltarr format_axis_values fstat fulstr fv_test fx_root fz_roots gamma gauss_cvf gauss_pdf gauss2dfit gaussfit gaussint get_drive_list get_kbrd get_screen_size getenv grid_tps grid3 griddata gs_iter hanning hdf_browser hdf_read hilbert hist_2d hist_equal histogram hough hqr ibeta identity idl_validname idlitsys_createtool igamma imaginary indgen int_2d int_3d int_tabulated intarr interpol interpolate invert ioctl ishft julday keword_set krig2d kurtosis kw_test l64indgen label_date label_region ladfit laguerre la_cholmprove la_cholsol la_Determ la_eigenproblem la_eigenql la_eigenvec la_elmhes la_gm_linear_model la_hqr la_invert la_least_square_equality la_least_squares la_linear_equation la_lumprove la_lusol la_trimprove la_trisol leefit legendre linbcg lindgen linfit ll_arc_distance lmfit lmgr lngamma lnp_test locale_get logical_and logical_or logical_true lon64arr lonarr long long64 lsode lu_complex lumprove lusol m_correlate machar make_array map_2points map_image map_patch map_proj_forward map_proj_init map_proj_inverse matrix_multiply matrix_power max md_test mean meanabsdev median memory mesh_clip mesh_decimate mesh_issolid mesh_merge mesh_numtriangles mesh_smooth mesh_surfacearea mesh_validate mesh_volume min min_curve_surf moment morph_close morph_distance morph_gradient morph_histormiss morph_open morph_thin morph_tophat mpeg_open msg_cat_open n_elements n_params n_tags newton norm obj_class obj_isa obj_new obj_valid objarr p_correlate path_sep pcomp pnt_line polar_surface poly poly_2d poly_area poly_fit polyfillv ployshade primes product profile profiles project_vol ptr_new ptr_valid ptrarr qgrid3 qromb qromo qsimp query_bmp query_dicom query_image query_jpeg query_mrsid query_pict query_png query_ppm query_srf query_tiff query_wav r_correlate r_test radon randomn randomu ranks read_ascii read_binary read_bmp read_dicom read_image read_mrsid read_png read_spr read_sylk read_tiff read_wav read_xwd real_part rebin recall_commands recon3 reform region_grow regress replicate reverse rk4 roberts rot rotate round routine_info rs_test s_test savgol search2d search3d sfit shift shmdebug shmvar simplex sin sindgen sinh size skewness smooth sobel sort sph_scat spher_harm spl_init spl_interp spline spline_p sprsab sprsax sprsin sprstp sqrt standardize stddev strarr strcmp strcompress stregex string strjoin strlen strlowcase strmatch strmessage strmid strpos strsplit strtrim strupcase svdfit svsol swap_endian systime t_cvf t_pdf tag_names tan tanh temporary tetra_clip tetra_surface tetra_volume thin timegen tm_test total trace transpose tri_surf trigrid trisol ts_coef ts_diff ts_fcast ts_smooth tvrd uindgen unit uintarr ul64indgen ulindgen ulon64arr ulonarr ulong ulong64 uniq value_locate variance vert_t3d voigt voxel_proj warp_tri watershed where widget_actevix widget_base widget_button widget_combobox widget_draw widget_droplist widget_event widget_info widget_label widget_list widget_propertsheet widget_slider widget_tab widget_table widget_text widget_tree write_sylk wtn xfont xregistered xsq_test' + bistr2 = 'annotate arrow axis bar_plot blas_axpy box_cursor breakpoint byteorder caldata calendar call_method call_procedure catch cd cir_3pnt close color_convert compile_opt constrained_min contour copy_lun cpu create_view cursor cw_animate_getp cw_animate_load cw_animate_run cw_light_editor_get cw_light_editor_set cw_palette_editor_get cw_palette_editor_set define_key define_msgblk define_msgblk_from_file defsysv delvar device dfpmin dissolve dlm_load doc_librar draw_roi efont empty enable_sysrtn erase errplot expand file_chmod file_copy file_delete file_lines file_link file_mkdir file_move file_readlink flick flow3 flush forward_function free_lun funct gamma_ct get_lun grid_input h_eq_ct h_eq_int heap_free heap_gc hls hsv icontour iimage image_cont image_statistics internal_volume iplot isocontour isosurface isurface itcurrent itdelete itgetcurrent itregister itreset ivolume journal la_choldc la_ludc la_svd la_tridc la_triql la_trired linkimage loadct ludc make_dll map_continents map_grid map_proj_info map_set mesh_obj mk_html_help modifyct mpeg_close mpeg_put mpeg_save msg_cat_close msg_cat_compile multi obj_destroy on_error on_ioerror online_help openr openw openu oplot oploterr particle_trace path_cache plot plot_3dbox plot_field ploterr plots point_lun polar_contour polyfill polywarp popd powell printf printd ps_show_fonts psafm pseudo ptr_free pushd qhull rdpix readf read_interfile read_jpeg read_pict read_ppm read_srf read_wave read_x11_bitmap reads readu reduce_colors register_cursor replicate_inplace resolve_all resolve_routine restore save scale3 scale3d set_plot set_shading setenv setup_keys shade_surf shade_surf_irr shade_volume shmmap show3 showfont skip_lun slicer3 slide_image socket spawn sph_4pnt streamline stretch strput struct_assign struct_hide surface surfr svdc swap_enian_inplace t3d tek_color threed time_test2 triangulate triql trired truncate_lun tv tvcrs tvlct tvscl usersym vector_field vel velovect voronoi wait wdelete wf_draw widget_control widget_displaycontextmenu window write_bmp write_image write_jpeg write_nrif write_pict write_png write_ppm write_spr write_srf write_tiff write_wav write_wave writeu wset wshow xbm_edit xdisplayfile xdxf xinteranimate xloadct xmanager xmng_tmpl xmtool xobjview xobjview_rotate xobjview_write_image xpalette xpcolo xplot3d xroi xsurface xvaredit xvolume xyouts zoom zoom_24' + kw = r"\b" + any("keyword", kwstr.split()) + r"\b" + builtin = r"\b" + any("builtin", bistr1.split()+bistr2.split()) + r"\b" + comment = any("comment", [r"\;[^\n]*"]) + number = any("number", + [r"\b[+-]?[0-9]+[lL]?\b", + r"\b[+-]?0[xX][0-9A-Fa-f]+[lL]?\b", + r"\b\.[0-9]d0|\.d0+[lL]?\b", + r"\b[+-]?[0-9]+(?:\.[0-9]+)?(?:[eE][+-]?[0-9]+)?\b"]) + sqstring = r"(\b[rRuU])?'[^'\\\n]*(\\.[^'\\\n]*)*'?" + dqstring = r'(\b[rRuU])?"[^"\\\n]*(\\.[^"\\\n]*)*"?' + string = any("string", [sqstring, dqstring]) + return "|".join([kw, comment, string, number, builtin, + any("SYNC", [r"\n"])]) + +class IdlSH(GenericSH): + """IDL Syntax Highlighter""" + PROG = re.compile(make_idl_patterns(), re.S|re.I) + + +#============================================================================== +# Diff/Patch highlighter +#============================================================================== +class DiffSH(BaseSH): + """Simple Diff/Patch Syntax Highlighter Class""" + def highlight_block(self, text): + """Implement highlight specific Diff/Patch files.""" + text = to_text_string(text) + if text.startswith("+++"): + self.setFormat(0, qstring_length(text), self.formats["keyword"]) + elif text.startswith("---"): + self.setFormat(0, qstring_length(text), self.formats["keyword"]) + elif text.startswith("+"): + self.setFormat(0, qstring_length(text), self.formats["string"]) + elif text.startswith("-"): + self.setFormat(0, qstring_length(text), self.formats["number"]) + elif text.startswith("@"): + self.setFormat(0, qstring_length(text), self.formats["builtin"]) + + self.highlight_extras(text) + +#============================================================================== +# NSIS highlighter +#============================================================================== +def make_nsis_patterns(): + "Strongly inspired from idlelib.ColorDelegator.make_pat" + kwstr1 = 'Abort AddBrandingImage AddSize AllowRootDirInstall AllowSkipFiles AutoCloseWindow BGFont BGGradient BrandingText BringToFront Call CallInstDLL Caption ClearErrors CompletedText ComponentText CopyFiles CRCCheck CreateDirectory CreateFont CreateShortCut Delete DeleteINISec DeleteINIStr DeleteRegKey DeleteRegValue DetailPrint DetailsButtonText DirText DirVar DirVerify EnableWindow EnumRegKey EnumRegValue Exec ExecShell ExecWait Exch ExpandEnvStrings File FileBufSize FileClose FileErrorText FileOpen FileRead FileReadByte FileSeek FileWrite FileWriteByte FindClose FindFirst FindNext FindWindow FlushINI Function FunctionEnd GetCurInstType GetCurrentAddress GetDlgItem GetDLLVersion GetDLLVersionLocal GetErrorLevel GetFileTime GetFileTimeLocal GetFullPathName GetFunctionAddress GetInstDirError GetLabelAddress GetTempFileName Goto HideWindow ChangeUI CheckBitmap Icon IfAbort IfErrors IfFileExists IfRebootFlag IfSilent InitPluginsDir InstallButtonText InstallColors InstallDir InstallDirRegKey InstProgressFlags InstType InstTypeGetText InstTypeSetText IntCmp IntCmpU IntFmt IntOp IsWindow LangString LicenseBkColor LicenseData LicenseForceSelection LicenseLangString LicenseText LoadLanguageFile LogSet LogText MessageBox MiscButtonText Name OutFile Page PageCallbacks PageEx PageExEnd Pop Push Quit ReadEnvStr ReadINIStr ReadRegDWORD ReadRegStr Reboot RegDLL Rename ReserveFile Return RMDir SearchPath Section SectionEnd SectionGetFlags SectionGetInstTypes SectionGetSize SectionGetText SectionIn SectionSetFlags SectionSetInstTypes SectionSetSize SectionSetText SendMessage SetAutoClose SetBrandingImage SetCompress SetCompressor SetCompressorDictSize SetCtlColors SetCurInstType SetDatablockOptimize SetDateSave SetDetailsPrint SetDetailsView SetErrorLevel SetErrors SetFileAttributes SetFont SetOutPath SetOverwrite SetPluginUnload SetRebootFlag SetShellVarContext SetSilent ShowInstDetails ShowUninstDetails ShowWindow SilentInstall SilentUnInstall Sleep SpaceTexts StrCmp StrCpy StrLen SubCaption SubSection SubSectionEnd UninstallButtonText UninstallCaption UninstallIcon UninstallSubCaption UninstallText UninstPage UnRegDLL Var VIAddVersionKey VIProductVersion WindowIcon WriteINIStr WriteRegBin WriteRegDWORD WriteRegExpandStr WriteRegStr WriteUninstaller XPStyle' + kwstr2 = 'all alwaysoff ARCHIVE auto both bzip2 components current custom details directory false FILE_ATTRIBUTE_ARCHIVE FILE_ATTRIBUTE_HIDDEN FILE_ATTRIBUTE_NORMAL FILE_ATTRIBUTE_OFFLINE FILE_ATTRIBUTE_READONLY FILE_ATTRIBUTE_SYSTEM FILE_ATTRIBUTE_TEMPORARY force grey HIDDEN hide IDABORT IDCANCEL IDIGNORE IDNO IDOK IDRETRY IDYES ifdiff ifnewer instfiles instfiles lastused leave left level license listonly lzma manual MB_ABORTRETRYIGNORE MB_DEFBUTTON1 MB_DEFBUTTON2 MB_DEFBUTTON3 MB_DEFBUTTON4 MB_ICONEXCLAMATION MB_ICONINFORMATION MB_ICONQUESTION MB_ICONSTOP MB_OK MB_OKCANCEL MB_RETRYCANCEL MB_RIGHT MB_SETFOREGROUND MB_TOPMOST MB_YESNO MB_YESNOCANCEL nevershow none NORMAL off OFFLINE on READONLY right RO show silent silentlog SYSTEM TEMPORARY text textonly true try uninstConfirm windows zlib' + kwstr3 = 'MUI_ABORTWARNING MUI_ABORTWARNING_CANCEL_DEFAULT MUI_ABORTWARNING_TEXT MUI_BGCOLOR MUI_COMPONENTSPAGE_CHECKBITMAP MUI_COMPONENTSPAGE_NODESC MUI_COMPONENTSPAGE_SMALLDESC MUI_COMPONENTSPAGE_TEXT_COMPLIST MUI_COMPONENTSPAGE_TEXT_DESCRIPTION_INFO MUI_COMPONENTSPAGE_TEXT_DESCRIPTION_TITLE MUI_COMPONENTSPAGE_TEXT_INSTTYPE MUI_COMPONENTSPAGE_TEXT_TOP MUI_CUSTOMFUNCTION_ABORT MUI_CUSTOMFUNCTION_GUIINIT MUI_CUSTOMFUNCTION_UNABORT MUI_CUSTOMFUNCTION_UNGUIINIT MUI_DESCRIPTION_TEXT MUI_DIRECTORYPAGE_BGCOLOR MUI_DIRECTORYPAGE_TEXT_DESTINATION MUI_DIRECTORYPAGE_TEXT_TOP MUI_DIRECTORYPAGE_VARIABLE MUI_DIRECTORYPAGE_VERIFYONLEAVE MUI_FINISHPAGE_BUTTON MUI_FINISHPAGE_CANCEL_ENABLED MUI_FINISHPAGE_LINK MUI_FINISHPAGE_LINK_COLOR MUI_FINISHPAGE_LINK_LOCATION MUI_FINISHPAGE_NOAUTOCLOSE MUI_FINISHPAGE_NOREBOOTSUPPORT MUI_FINISHPAGE_REBOOTLATER_DEFAULT MUI_FINISHPAGE_RUN MUI_FINISHPAGE_RUN_FUNCTION MUI_FINISHPAGE_RUN_NOTCHECKED MUI_FINISHPAGE_RUN_PARAMETERS MUI_FINISHPAGE_RUN_TEXT MUI_FINISHPAGE_SHOWREADME MUI_FINISHPAGE_SHOWREADME_FUNCTION MUI_FINISHPAGE_SHOWREADME_NOTCHECKED MUI_FINISHPAGE_SHOWREADME_TEXT MUI_FINISHPAGE_TEXT MUI_FINISHPAGE_TEXT_LARGE MUI_FINISHPAGE_TEXT_REBOOT MUI_FINISHPAGE_TEXT_REBOOTLATER MUI_FINISHPAGE_TEXT_REBOOTNOW MUI_FINISHPAGE_TITLE MUI_FINISHPAGE_TITLE_3LINES MUI_FUNCTION_DESCRIPTION_BEGIN MUI_FUNCTION_DESCRIPTION_END MUI_HEADER_TEXT MUI_HEADER_TRANSPARENT_TEXT MUI_HEADERIMAGE MUI_HEADERIMAGE_BITMAP MUI_HEADERIMAGE_BITMAP_NOSTRETCH MUI_HEADERIMAGE_BITMAP_RTL MUI_HEADERIMAGE_BITMAP_RTL_NOSTRETCH MUI_HEADERIMAGE_RIGHT MUI_HEADERIMAGE_UNBITMAP MUI_HEADERIMAGE_UNBITMAP_NOSTRETCH MUI_HEADERIMAGE_UNBITMAP_RTL MUI_HEADERIMAGE_UNBITMAP_RTL_NOSTRETCH MUI_HWND MUI_ICON MUI_INSTALLCOLORS MUI_INSTALLOPTIONS_DISPLAY MUI_INSTALLOPTIONS_DISPLAY_RETURN MUI_INSTALLOPTIONS_EXTRACT MUI_INSTALLOPTIONS_EXTRACT_AS MUI_INSTALLOPTIONS_INITDIALOG MUI_INSTALLOPTIONS_READ MUI_INSTALLOPTIONS_SHOW MUI_INSTALLOPTIONS_SHOW_RETURN MUI_INSTALLOPTIONS_WRITE MUI_INSTFILESPAGE_ABORTHEADER_SUBTEXT MUI_INSTFILESPAGE_ABORTHEADER_TEXT MUI_INSTFILESPAGE_COLORS MUI_INSTFILESPAGE_FINISHHEADER_SUBTEXT MUI_INSTFILESPAGE_FINISHHEADER_TEXT MUI_INSTFILESPAGE_PROGRESSBAR MUI_LANGDLL_ALLLANGUAGES MUI_LANGDLL_ALWAYSSHOW MUI_LANGDLL_DISPLAY MUI_LANGDLL_INFO MUI_LANGDLL_REGISTRY_KEY MUI_LANGDLL_REGISTRY_ROOT MUI_LANGDLL_REGISTRY_VALUENAME MUI_LANGDLL_WINDOWTITLE MUI_LANGUAGE MUI_LICENSEPAGE_BGCOLOR MUI_LICENSEPAGE_BUTTON MUI_LICENSEPAGE_CHECKBOX MUI_LICENSEPAGE_CHECKBOX_TEXT MUI_LICENSEPAGE_RADIOBUTTONS MUI_LICENSEPAGE_RADIOBUTTONS_TEXT_ACCEPT MUI_LICENSEPAGE_RADIOBUTTONS_TEXT_DECLINE MUI_LICENSEPAGE_TEXT_BOTTOM MUI_LICENSEPAGE_TEXT_TOP MUI_PAGE_COMPONENTS MUI_PAGE_CUSTOMFUNCTION_LEAVE MUI_PAGE_CUSTOMFUNCTION_PRE MUI_PAGE_CUSTOMFUNCTION_SHOW MUI_PAGE_DIRECTORY MUI_PAGE_FINISH MUI_PAGE_HEADER_SUBTEXT MUI_PAGE_HEADER_TEXT MUI_PAGE_INSTFILES MUI_PAGE_LICENSE MUI_PAGE_STARTMENU MUI_PAGE_WELCOME MUI_RESERVEFILE_INSTALLOPTIONS MUI_RESERVEFILE_LANGDLL MUI_SPECIALINI MUI_STARTMENU_GETFOLDER MUI_STARTMENU_WRITE_BEGIN MUI_STARTMENU_WRITE_END MUI_STARTMENUPAGE_BGCOLOR MUI_STARTMENUPAGE_DEFAULTFOLDER MUI_STARTMENUPAGE_NODISABLE MUI_STARTMENUPAGE_REGISTRY_KEY MUI_STARTMENUPAGE_REGISTRY_ROOT MUI_STARTMENUPAGE_REGISTRY_VALUENAME MUI_STARTMENUPAGE_TEXT_CHECKBOX MUI_STARTMENUPAGE_TEXT_TOP MUI_UI MUI_UI_COMPONENTSPAGE_NODESC MUI_UI_COMPONENTSPAGE_SMALLDESC MUI_UI_HEADERIMAGE MUI_UI_HEADERIMAGE_RIGHT MUI_UNABORTWARNING MUI_UNABORTWARNING_CANCEL_DEFAULT MUI_UNABORTWARNING_TEXT MUI_UNCONFIRMPAGE_TEXT_LOCATION MUI_UNCONFIRMPAGE_TEXT_TOP MUI_UNFINISHPAGE_NOAUTOCLOSE MUI_UNFUNCTION_DESCRIPTION_BEGIN MUI_UNFUNCTION_DESCRIPTION_END MUI_UNGETLANGUAGE MUI_UNICON MUI_UNPAGE_COMPONENTS MUI_UNPAGE_CONFIRM MUI_UNPAGE_DIRECTORY MUI_UNPAGE_FINISH MUI_UNPAGE_INSTFILES MUI_UNPAGE_LICENSE MUI_UNPAGE_WELCOME MUI_UNWELCOMEFINISHPAGE_BITMAP MUI_UNWELCOMEFINISHPAGE_BITMAP_NOSTRETCH MUI_UNWELCOMEFINISHPAGE_INI MUI_WELCOMEFINISHPAGE_BITMAP MUI_WELCOMEFINISHPAGE_BITMAP_NOSTRETCH MUI_WELCOMEFINISHPAGE_CUSTOMFUNCTION_INIT MUI_WELCOMEFINISHPAGE_INI MUI_WELCOMEPAGE_TEXT MUI_WELCOMEPAGE_TITLE MUI_WELCOMEPAGE_TITLE_3LINES' + bistr = 'addincludedir addplugindir AndIf cd define echo else endif error execute If ifdef ifmacrodef ifmacrondef ifndef include insertmacro macro macroend onGUIEnd onGUIInit onInit onInstFailed onInstSuccess onMouseOverSection onRebootFailed onSelChange onUserAbort onVerifyInstDir OrIf packhdr system undef verbose warning' + instance = any("instance", [r'\$\{.*?\}', r'\$[A-Za-z0-9\_]*']) + define = any("define", [r"\![^\n]*"]) + comment = any("comment", [r"\;[^\n]*", r"\#[^\n]*", r"\/\*(.*?)\*\/"]) + return make_generic_c_patterns(kwstr1+' '+kwstr2+' '+kwstr3, bistr, + instance=instance, define=define, + comment=comment) + +class NsisSH(CppSH): + """NSIS Syntax Highlighter""" + # Syntax highlighting rules: + PROG = re.compile(make_nsis_patterns(), re.S) + + +#============================================================================== +# gettext highlighter +#============================================================================== +def make_gettext_patterns(): + "Strongly inspired from idlelib.ColorDelegator.make_pat" + kwstr = 'msgid msgstr' + kw = r"\b" + any("keyword", kwstr.split()) + r"\b" + fuzzy = any("builtin", [r"#,[^\n]*"]) + links = any("normal", [r"#:[^\n]*"]) + comment = any("comment", [r"#[^\n]*"]) + number = any("number", + [r"\b[+-]?[0-9]+[lL]?\b", + r"\b[+-]?0[xX][0-9A-Fa-f]+[lL]?\b", + r"\b[+-]?[0-9]+(?:\.[0-9]+)?(?:[eE][+-]?[0-9]+)?\b"]) + sqstring = r"(\b[rRuU])?'[^'\\\n]*(\\.[^'\\\n]*)*'?" + dqstring = r'(\b[rRuU])?"[^"\\\n]*(\\.[^"\\\n]*)*"?' + string = any("string", [sqstring, dqstring]) + return "|".join([kw, string, number, fuzzy, links, comment, + any("SYNC", [r"\n"])]) + +class GetTextSH(GenericSH): + """gettext Syntax Highlighter""" + # Syntax highlighting rules: + PROG = re.compile(make_gettext_patterns(), re.S) + +#============================================================================== +# yaml highlighter +#============================================================================== +def make_yaml_patterns(): + "Strongly inspired from sublime highlighter " + kw = any("keyword", [r":|>|-|\||\[|\]|[A-Za-z][\w\s\-\_ ]+(?=:)"]) + links = any("normal", [r"#:[^\n]*"]) + comment = any("comment", [r"#[^\n]*"]) + number = any("number", + [r"\b[+-]?[0-9]+[lL]?\b", + r"\b[+-]?0[xX][0-9A-Fa-f]+[lL]?\b", + r"\b[+-]?[0-9]+(?:\.[0-9]+)?(?:[eE][+-]?[0-9]+)?\b"]) + sqstring = r"(\b[rRuU])?'[^'\\\n]*(\\.[^'\\\n]*)*'?" + dqstring = r'(\b[rRuU])?"[^"\\\n]*(\\.[^"\\\n]*)*"?' + string = any("string", [sqstring, dqstring]) + return "|".join([kw, string, number, links, comment, + any("SYNC", [r"\n"])]) + +class YamlSH(GenericSH): + """yaml Syntax Highlighter""" + # Syntax highlighting rules: + PROG = re.compile(make_yaml_patterns(), re.S) + + +#============================================================================== +# HTML highlighter +#============================================================================== +class BaseWebSH(BaseSH): + """Base class for CSS and HTML syntax highlighters""" + NORMAL = 0 + COMMENT = 1 + + def __init__(self, parent, font=None, color_scheme=None): + BaseSH.__init__(self, parent, font, color_scheme) + + def highlight_block(self, text): + """Implement highlight specific for CSS and HTML.""" + text = to_text_string(text) + previous_state = tbh.get_state(self.currentBlock().previous()) + + if previous_state == self.COMMENT: + self.setFormat(0, qstring_length(text), self.formats["comment"]) + else: + previous_state = self.NORMAL + self.setFormat(0, qstring_length(text), self.formats["normal"]) + + tbh.set_state(self.currentBlock(), previous_state) + + match_count = 0 + n_characters = qstring_length(text) + # There should never be more matches than characters in the text. + for match in self.PROG.finditer(text): + match_dict = match.groupdict() + for key, value in list(match_dict.items()): + if value: + start, end = get_span(match, key) + if previous_state == self.COMMENT: + if key == "multiline_comment_end": + tbh.set_state(self.currentBlock(), self.NORMAL) + self.setFormat(end, qstring_length(text), + self.formats["normal"]) + else: + tbh.set_state(self.currentBlock(), self.COMMENT) + self.setFormat(0, qstring_length(text), + self.formats["comment"]) + else: + if key == "multiline_comment_start": + tbh.set_state(self.currentBlock(), self.COMMENT) + self.setFormat(start, qstring_length(text), + self.formats["comment"]) + else: + tbh.set_state(self.currentBlock(), self.NORMAL) + try: + self.setFormat(start, end-start, + self.formats[key]) + except KeyError: + # Happens with unmatched end-of-comment. + # See spyder-ide/spyder#1462. + pass + match_count += 1 + if match_count >= n_characters: + break + + self.highlight_extras(text) + +def make_html_patterns(): + """Strongly inspired from idlelib.ColorDelegator.make_pat """ + tags = any("builtin", [r"<", r"[\?/]?>", r"(?<=<).*?(?=[ >])"]) + keywords = any("keyword", [r" [\w:-]*?(?==)"]) + string = any("string", [r'".*?"']) + comment = any("comment", [r""]) + multiline_comment_start = any("multiline_comment_start", [r""]) + return "|".join([comment, multiline_comment_start, + multiline_comment_end, tags, keywords, string]) + +class HtmlSH(BaseWebSH): + """HTML Syntax Highlighter""" + PROG = re.compile(make_html_patterns(), re.S) + + +# ============================================================================= +# Markdown highlighter +# ============================================================================= +def make_md_patterns(): + h1 = '^#[^#]+' + h2 = '^##[^#]+' + h3 = '^###[^#]+' + h4 = '^####[^#]+' + h5 = '^#####[^#]+' + h6 = '^######[^#]+' + + titles = any('title', [h1, h2, h3, h4, h5, h6]) + + html_tags = any("builtin", [r"<", r"[\?/]?>", r"(?<=<).*?(?=[ >])"]) + html_symbols = '&[^; ].+;' + html_comment = '' + + strikethrough = any('strikethrough', [r'(~~)(.*?)~~']) + strong = any('strong', [r'(\*\*)(.*?)\*\*']) + + italic = r'(__)(.*?)__' + emphasis = r'(//)(.*?)//' + italic = any('italic', [italic, emphasis]) + + # links - (links) after [] or links after []: + link_html = (r'(?<=(\]\())[^\(\)]*(?=\))|' + '(]+>)|' + '(<[^ >]+@[^ >]+>)') + # link/image references - [] or ![] + link = r'!?\[[^\[\]]*\]' + links = any('link', [link_html, link]) + + # blockquotes and lists - > or - or * or 0. + blockquotes = (r'(^>+.*)' + r'|(^(?: |\t)*[0-9]+\. )' + r'|(^(?: |\t)*- )' + r'|(^(?: |\t)*\* )') + # code + code = any('code', ['^`{3,}.*$']) + inline_code = any('inline_code', ['`[^`]*`']) + + # math - $$ + math = any('number', [r'^(?:\${2}).*$', html_symbols]) + + comment = any('comment', [blockquotes, html_comment]) + + return '|'.join([titles, comment, html_tags, math, links, italic, strong, + strikethrough, code, inline_code]) + + +class MarkdownSH(BaseSH): + """Markdown Syntax Highlighter""" + # Syntax highlighting rules: + PROG = re.compile(make_md_patterns(), re.S) + NORMAL = 0 + CODE = 1 + + def highlightBlock(self, text): + text = to_text_string(text) + previous_state = self.previousBlockState() + + if previous_state == self.CODE: + self.setFormat(0, qstring_length(text), self.formats["code"]) + else: + previous_state = self.NORMAL + self.setFormat(0, qstring_length(text), self.formats["normal"]) + + self.setCurrentBlockState(previous_state) + + match_count = 0 + n_characters = qstring_length(text) + for match in self.PROG.finditer(text): + for key, value in list(match.groupdict().items()): + start, end = get_span(match, key) + + if value: + previous_state = self.previousBlockState() + + if previous_state == self.CODE: + if key == "code": + # Change to normal + self.setFormat(0, qstring_length(text), + self.formats["normal"]) + self.setCurrentBlockState(self.NORMAL) + else: + continue + else: + if key == "code": + # Change to code + self.setFormat(0, qstring_length(text), + self.formats["code"]) + self.setCurrentBlockState(self.CODE) + continue + + self.setFormat(start, end - start, self.formats[key]) + + match_count += 1 + if match_count >= n_characters: + break + + self.highlight_extras(text) + + def setup_formats(self, font=None): + super(MarkdownSH, self).setup_formats(font) + + font = QTextCharFormat(self.formats['normal']) + font.setFontItalic(True) + self.formats['italic'] = font + + self.formats['strong'] = self.formats['definition'] + + font = QTextCharFormat(self.formats['normal']) + font.setFontStrikeOut(True) + self.formats['strikethrough'] = font + + font = QTextCharFormat(self.formats['string']) + font.setUnderlineStyle(QTextCharFormat.SingleUnderline) + self.formats['link'] = font + + self.formats['code'] = self.formats['string'] + self.formats['inline_code'] = self.formats['string'] + + font = QTextCharFormat(self.formats['keyword']) + font.setFontWeight(QFont.Bold) + self.formats['title'] = font + + +#============================================================================== +# Pygments based omni-parser +#============================================================================== +# IMPORTANT NOTE: +# -------------- +# Do not be tempted to generalize the use of PygmentsSH (that is tempting +# because it would lead to more generic and compact code, and not only in +# this very module) because this generic syntax highlighter is far slower +# than the native ones (all classes above). For example, a Python syntax +# highlighter based on PygmentsSH would be 2 to 3 times slower than the +# current native PythonSH syntax highlighter. + +class PygmentsSH(BaseSH): + """Generic Pygments syntax highlighter.""" + # Store the language name and a ref to the lexer + _lang_name = None + _lexer = None + + # Syntax highlighting states (from one text block to another): + NORMAL = 0 + def __init__(self, parent, font=None, color_scheme=None): + # Map Pygments tokens to Spyder tokens + self._tokmap = {Text: "normal", + Generic: "normal", + Other: "normal", + Keyword: "keyword", + Token.Operator: "normal", + Name.Builtin: "builtin", + Name: "normal", + Comment: "comment", + String: "string", + Number: "number"} + # Load Pygments' Lexer + if self._lang_name is not None: + self._lexer = get_lexer_by_name(self._lang_name) + + BaseSH.__init__(self, parent, font, color_scheme) + + # This worker runs in a thread to avoid blocking when doing full file + # parsing + self._worker_manager = WorkerManager() + + # Store the format for all the tokens after Pygments parsing + self._charlist = [] + + # Flag variable to avoid unnecessary highlights if the worker has not + # yet finished processing + self._allow_highlight = True + + def stop(self): + self._worker_manager.terminate_all() + + def make_charlist(self): + """Parses the complete text and stores format for each character.""" + + def worker_output(worker, output, error): + """Worker finished callback.""" + self._charlist = output + if error is None and output: + self._allow_highlight = True + self.rehighlight() + self._allow_highlight = False + + text = to_text_string(self.document().toPlainText()) + tokens = self._lexer.get_tokens(text) + + # Before starting a new worker process make sure to end previous + # incarnations + self._worker_manager.terminate_all() + + worker = self._worker_manager.create_python_worker( + self._make_charlist, + tokens, + self._tokmap, + self.formats, + ) + worker.sig_finished.connect(worker_output) + worker.start() + + def _make_charlist(self, tokens, tokmap, formats): + """ + Parses the complete text and stores format for each character. + + Uses the attached lexer to parse into a list of tokens and Pygments + token types. Then breaks tokens into individual letters, each with a + Spyder token type attached. Stores this list as self._charlist. + + It's attached to the contentsChange signal of the parent QTextDocument + so that the charlist is updated whenever the document changes. + """ + + def _get_fmt(typ): + """Get the Spyder format code for the given Pygments token type.""" + # Exact matches first + if typ in tokmap: + return tokmap[typ] + # Partial (parent-> child) matches + for key, val in tokmap.items(): + if typ in key: # Checks if typ is a subtype of key. + return val + + return 'normal' + + charlist = [] + for typ, token in tokens: + fmt = formats[_get_fmt(typ)] + for letter in token: + charlist.append((fmt, letter)) + + return charlist + + def highlightBlock(self, text): + """ Actually highlight the block""" + # Note that an undefined blockstate is equal to -1, so the first block + # will have the correct behaviour of starting at 0. + if self._allow_highlight: + start = self.previousBlockState() + 1 + end = start + qstring_length(text) + for i, (fmt, letter) in enumerate(self._charlist[start:end]): + self.setFormat(i, 1, fmt) + self.setCurrentBlockState(end) + self.highlight_extras(text) + + +class PythonLoggingLexer(RegexLexer): + """ + A lexer for logs generated by the Python builtin 'logging' library. + + Taken from + https://bitbucket.org/birkenfeld/pygments-main/pull-requests/451/add-python-logging-lexer + """ + + name = 'Python Logging' + aliases = ['pylog', 'pythonlogging'] + filenames = ['*.log'] + tokens = { + 'root': [ + (r'^(\d{4}-\d\d-\d\d \d\d:\d\d:\d\d\,?\d*)(\s\w+)', + bygroups(Comment.Preproc, Number.Integer), 'message'), + (r'"(.*?)"|\'(.*?)\'', String), + (r'(\d)', Number.Integer), + (r'(\s.+/n)', Text) + ], + + 'message': [ + (r'(\s-)(\sDEBUG)(\s-)(\s*[\d\w]+([.]?[\d\w]+)+\s*)', + bygroups(Text, Number, Text, Name.Builtin), '#pop'), + (r'(\s-)(\sINFO\w*)(\s-)(\s*[\d\w]+([.]?[\d\w]+)+\s*)', + bygroups(Generic.Heading, Text, Text, Name.Builtin), '#pop'), + (r'(\sWARN\w*)(\s.+)', bygroups(String, String), '#pop'), + (r'(\sERROR)(\s.+)', + bygroups(Generic.Error, Name.Constant), '#pop'), + (r'(\sCRITICAL)(\s.+)', + bygroups(Generic.Error, Name.Constant), '#pop'), + (r'(\sTRACE)(\s.+)', + bygroups(Generic.Error, Name.Constant), '#pop'), + (r'(\s\w+)(\s.+)', + bygroups(Comment, Generic.Output), '#pop'), + ], + + } + + +def guess_pygments_highlighter(filename): + """ + Factory to generate syntax highlighter for the given filename. + + If a syntax highlighter is not available for a particular file, this + function will attempt to generate one based on the lexers in Pygments. If + Pygments is not available or does not have an appropriate lexer, TextSH + will be returned instead. + """ + try: + from pygments.lexers import get_lexer_for_filename, get_lexer_by_name + except Exception: + return TextSH + + root, ext = os.path.splitext(filename) + if ext == '.txt': + # Pygments assigns a lexer that doesn’t highlight anything to + # txt files. So we avoid that here. + return TextSH + elif ext in custom_extension_lexer_mapping: + try: + lexer = get_lexer_by_name(custom_extension_lexer_mapping[ext]) + except Exception: + return TextSH + elif ext == '.log': + lexer = PythonLoggingLexer() + else: + try: + lexer = get_lexer_for_filename(filename) + except Exception: + return TextSH + + class GuessedPygmentsSH(PygmentsSH): + _lexer = lexer + + return GuessedPygmentsSH diff --git a/spyder/utils/system.py b/spyder/utils/system.py index b843e7679ae..064e9c0fd50 100644 --- a/spyder/utils/system.py +++ b/spyder/utils/system.py @@ -1,65 +1,65 @@ -# -*- coding: utf-8 -*- -# -# Copyright (c) 2009- Spyder Project Contributors -# -# Distributed under the terms of the MIT License -# (see spyder/__init__.py for details) - -""" -Operating-system-specific utilities. -""" - -# Standard library imports -import os - -# Third-party imports -import psutil - - -def windows_memory_usage(): - """ - Return physical memory usage (float). - - It works on Windows platforms only and without psutil. - """ - from ctypes import windll, Structure, c_uint64, sizeof, byref - from ctypes.wintypes import DWORD - class MemoryStatus(Structure): - _fields_ = [('dwLength', DWORD), - ('dwMemoryLoad',DWORD), - ('ullTotalPhys', c_uint64), - ('ullAvailPhys', c_uint64), - ('ullTotalPageFile', c_uint64), - ('ullAvailPageFile', c_uint64), - ('ullTotalVirtual', c_uint64), - ('ullAvailVirtual', c_uint64), - ('ullAvailExtendedVirtual', c_uint64),] - memorystatus = MemoryStatus() - # MSDN documentation states that dwLength must be set to MemoryStatus - # size before calling GlobalMemoryStatusEx - # https://msdn.microsoft.com/en-us/library/aa366770(v=vs.85) - memorystatus.dwLength = sizeof(memorystatus) - windll.kernel32.GlobalMemoryStatusEx(byref(memorystatus)) - return float(memorystatus.dwMemoryLoad) - - -def memory_usage(): - """Return physical memory usage (float).""" - # This is needed to avoid a deprecation warning error with - # newer psutil versions - try: - percent = psutil.virtual_memory().percent - except: - percent = psutil.phymem_usage().percent - return percent - - -if __name__ == '__main__': - print("*"*80) # spyder: test-skip - print(memory_usage.__doc__) # spyder: test-skip - print(memory_usage()) # spyder: test-skip - if os.name == 'nt': - # windll can only be imported if os.name = 'nt' or 'ce' - print("*"*80) # spyder: test-skip - print(windows_memory_usage.__doc__) # spyder: test-skip - print(windows_memory_usage()) # spyder: test-skip +# -*- coding: utf-8 -*- +# +# Copyright (c) 2009- Spyder Project Contributors +# +# Distributed under the terms of the MIT License +# (see spyder/__init__.py for details) + +""" +Operating-system-specific utilities. +""" + +# Standard library imports +import os + +# Third-party imports +import psutil + + +def windows_memory_usage(): + """ + Return physical memory usage (float). + + It works on Windows platforms only and without psutil. + """ + from ctypes import windll, Structure, c_uint64, sizeof, byref + from ctypes.wintypes import DWORD + class MemoryStatus(Structure): + _fields_ = [('dwLength', DWORD), + ('dwMemoryLoad',DWORD), + ('ullTotalPhys', c_uint64), + ('ullAvailPhys', c_uint64), + ('ullTotalPageFile', c_uint64), + ('ullAvailPageFile', c_uint64), + ('ullTotalVirtual', c_uint64), + ('ullAvailVirtual', c_uint64), + ('ullAvailExtendedVirtual', c_uint64),] + memorystatus = MemoryStatus() + # MSDN documentation states that dwLength must be set to MemoryStatus + # size before calling GlobalMemoryStatusEx + # https://msdn.microsoft.com/en-us/library/aa366770(v=vs.85) + memorystatus.dwLength = sizeof(memorystatus) + windll.kernel32.GlobalMemoryStatusEx(byref(memorystatus)) + return float(memorystatus.dwMemoryLoad) + + +def memory_usage(): + """Return physical memory usage (float).""" + # This is needed to avoid a deprecation warning error with + # newer psutil versions + try: + percent = psutil.virtual_memory().percent + except: + percent = psutil.phymem_usage().percent + return percent + + +if __name__ == '__main__': + print("*"*80) # spyder: test-skip + print(memory_usage.__doc__) # spyder: test-skip + print(memory_usage()) # spyder: test-skip + if os.name == 'nt': + # windll can only be imported if os.name = 'nt' or 'ce' + print("*"*80) # spyder: test-skip + print(windows_memory_usage.__doc__) # spyder: test-skip + print(windows_memory_usage()) # spyder: test-skip diff --git a/spyder/utils/vcs.py b/spyder/utils/vcs.py index 5445f580678..7e7a6e0cb5b 100644 --- a/spyder/utils/vcs.py +++ b/spyder/utils/vcs.py @@ -1,244 +1,244 @@ -# -*- coding: utf-8 -*- -# -# Copyright © Spyder Project Contributors -# Licensed under the terms of the MIT License -# (see spyder/__init__.py for details) - -"""Utilities for version control systems""" - -from __future__ import print_function - -import os -import os.path as osp -import subprocess -import sys - -# Local imports -from spyder.config.base import running_under_pytest -from spyder.utils import programs -from spyder.utils.misc import abspardir -from spyder.py3compat import PY3 - - -SUPPORTED = [ -{ - 'name': 'Mercurial', - 'rootdir': '.hg', - 'actions': dict( - commit=( ('thg', ['commit']), - ('hgtk', ['commit']) ), - browse=( ('thg', ['log']), - ('hgtk', ['log']) )) -}, { - 'name': 'Git', - 'rootdir': '.git', - 'actions': dict( - commit=( ('git', ['gui' if os.name == 'nt' else 'cola']), ), - browse=( ('gitk', []), )) -}] - - -class ActionToolNotFound(RuntimeError): - """Exception to transmit information about supported tools for - failed attempt to execute given action""" - - def __init__(self, vcsname, action, tools): - RuntimeError.__init__(self) - self.vcsname = vcsname - self.action = action - self.tools = tools - - -def get_vcs_info(path): - """Return support status dict if path is under VCS root""" - for info in SUPPORTED: - vcs_path = osp.join(path, info['rootdir']) - if osp.isdir(vcs_path): - return info - - -def get_vcs_root(path): - """Return VCS root directory path - Return None if path is not within a supported VCS repository""" - previous_path = path - while get_vcs_info(path) is None: - path = abspardir(path) - if path == previous_path: - return - else: - previous_path = path - return osp.abspath(path) - - -def is_vcs_repository(path): - """Return True if path is a supported VCS repository""" - return get_vcs_root(path) is not None - - -def run_vcs_tool(path, action): - """If path is a valid VCS repository, run the corresponding VCS tool - Supported VCS actions: 'commit', 'browse' - Return False if the VCS tool is not installed""" - info = get_vcs_info(get_vcs_root(path)) - tools = info['actions'][action] - for tool, args in tools: - if programs.find_program(tool): - if not running_under_pytest(): - programs.run_program(tool, args, cwd=path) - else: - return True - return - else: - cmdnames = [name for name, args in tools] - raise ActionToolNotFound(info['name'], action, cmdnames) - -def is_hg_installed(): - """Return True if Mercurial is installed""" - return programs.find_program('hg') is not None - - -def get_hg_revision(repopath): - """Return Mercurial revision for the repository located at repopath - Result is a tuple (global, local, branch), with None values on error - For example: - >>> get_hg_revision(".") - ('eba7273c69df+', '2015+', 'default') - """ - try: - assert osp.isdir(osp.join(repopath, '.hg')) - proc = programs.run_program('hg', ['id', '-nib', repopath]) - output, _err = proc.communicate() - # output is now: ('eba7273c69df+ 2015+ default\n', None) - # Split 2 times max to allow spaces in branch names. - return tuple(output.decode().strip().split(None, 2)) - except (subprocess.CalledProcessError, AssertionError, AttributeError, - OSError): - return (None, None, None) - - -def get_git_revision(repopath): - """ - Return Git revision for the repository located at repopath - - Result is a tuple (latest commit hash, branch), with None values on - error - """ - try: - git = programs.find_git() - assert git is not None and osp.isdir(osp.join(repopath, '.git')) - commit = programs.run_program(git, ['rev-parse', '--short', 'HEAD'], - cwd=repopath).communicate() - commit = commit[0].strip() - if PY3: - commit = commit.decode(sys.getdefaultencoding()) - - # Branch - branches = programs.run_program(git, ['branch'], - cwd=repopath).communicate() - branches = branches[0] - if PY3: - branches = branches.decode(sys.getdefaultencoding()) - branches = branches.split('\n') - active_branch = [b for b in branches if b.startswith('*')] - if len(active_branch) != 1: - branch = None - else: - branch = active_branch[0].split(None, 1)[1] - - return commit, branch - except (subprocess.CalledProcessError, AssertionError, AttributeError, - OSError): - return None, None - - -def get_git_refs(repopath): - """ - Return Git active branch, state, branches (plus tags). - """ - tags = [] - branches = [] - branch = '' - files_modifed = [] - - if os.path.isfile(repopath): - repopath = os.path.dirname(repopath) - - git = programs.find_git() - - if git: - try: - # Files modified - out, err = programs.run_program( - git, ['status', '-s'], - cwd=repopath, - ).communicate() - - if PY3: - out = out.decode(sys.getdefaultencoding()) - files_modifed = [line.strip() for line in out.split('\n') if line] - - # Tags - out, err = programs.run_program( - git, ['tag'], - cwd=repopath, - ).communicate() - - if PY3: - out = out.decode(sys.getdefaultencoding()) - tags = [line.strip() for line in out.split('\n') if line] - - # Branches - out, err = programs.run_program( - git, ['branch', '-a'], - cwd=repopath, - ).communicate() - - if PY3: - out = out.decode(sys.getdefaultencoding()) - - lines = [line.strip() for line in out.split('\n') if line] - for line in lines: - if line.startswith('*'): - line = line.replace('*', '').strip() - branch = line - - branches.append(line) - - except (subprocess.CalledProcessError, AttributeError, OSError): - pass - - return branches + tags, branch, files_modifed - - -def get_git_remotes(fpath): - """Return git remotes for repo on fpath.""" - remote_data = {} - data, __ = programs.run_program( - 'git', - ['remote', '-v'], - cwd=osp.dirname(fpath), - ).communicate() - - if PY3: - data = data.decode(sys.getdefaultencoding()) - - lines = [line.strip() for line in data.split('\n') if line] - for line in lines: - if line: - remote, value = line.split('\t') - remote_data[remote] = value.split(' ')[0] - - return remote_data - - -def remote_to_url(remote): - """Convert a git remote to a url.""" - url = '' - if remote.startswith('git@'): - url = remote.replace('git@', '') - url = url.replace(':', '/') - url = 'https://' + url.replace('.git', '') - else: - url = remote.replace('.git', '') - - return url +# -*- coding: utf-8 -*- +# +# Copyright © Spyder Project Contributors +# Licensed under the terms of the MIT License +# (see spyder/__init__.py for details) + +"""Utilities for version control systems""" + +from __future__ import print_function + +import os +import os.path as osp +import subprocess +import sys + +# Local imports +from spyder.config.base import running_under_pytest +from spyder.utils import programs +from spyder.utils.misc import abspardir +from spyder.py3compat import PY3 + + +SUPPORTED = [ +{ + 'name': 'Mercurial', + 'rootdir': '.hg', + 'actions': dict( + commit=( ('thg', ['commit']), + ('hgtk', ['commit']) ), + browse=( ('thg', ['log']), + ('hgtk', ['log']) )) +}, { + 'name': 'Git', + 'rootdir': '.git', + 'actions': dict( + commit=( ('git', ['gui' if os.name == 'nt' else 'cola']), ), + browse=( ('gitk', []), )) +}] + + +class ActionToolNotFound(RuntimeError): + """Exception to transmit information about supported tools for + failed attempt to execute given action""" + + def __init__(self, vcsname, action, tools): + RuntimeError.__init__(self) + self.vcsname = vcsname + self.action = action + self.tools = tools + + +def get_vcs_info(path): + """Return support status dict if path is under VCS root""" + for info in SUPPORTED: + vcs_path = osp.join(path, info['rootdir']) + if osp.isdir(vcs_path): + return info + + +def get_vcs_root(path): + """Return VCS root directory path + Return None if path is not within a supported VCS repository""" + previous_path = path + while get_vcs_info(path) is None: + path = abspardir(path) + if path == previous_path: + return + else: + previous_path = path + return osp.abspath(path) + + +def is_vcs_repository(path): + """Return True if path is a supported VCS repository""" + return get_vcs_root(path) is not None + + +def run_vcs_tool(path, action): + """If path is a valid VCS repository, run the corresponding VCS tool + Supported VCS actions: 'commit', 'browse' + Return False if the VCS tool is not installed""" + info = get_vcs_info(get_vcs_root(path)) + tools = info['actions'][action] + for tool, args in tools: + if programs.find_program(tool): + if not running_under_pytest(): + programs.run_program(tool, args, cwd=path) + else: + return True + return + else: + cmdnames = [name for name, args in tools] + raise ActionToolNotFound(info['name'], action, cmdnames) + +def is_hg_installed(): + """Return True if Mercurial is installed""" + return programs.find_program('hg') is not None + + +def get_hg_revision(repopath): + """Return Mercurial revision for the repository located at repopath + Result is a tuple (global, local, branch), with None values on error + For example: + >>> get_hg_revision(".") + ('eba7273c69df+', '2015+', 'default') + """ + try: + assert osp.isdir(osp.join(repopath, '.hg')) + proc = programs.run_program('hg', ['id', '-nib', repopath]) + output, _err = proc.communicate() + # output is now: ('eba7273c69df+ 2015+ default\n', None) + # Split 2 times max to allow spaces in branch names. + return tuple(output.decode().strip().split(None, 2)) + except (subprocess.CalledProcessError, AssertionError, AttributeError, + OSError): + return (None, None, None) + + +def get_git_revision(repopath): + """ + Return Git revision for the repository located at repopath + + Result is a tuple (latest commit hash, branch), with None values on + error + """ + try: + git = programs.find_git() + assert git is not None and osp.isdir(osp.join(repopath, '.git')) + commit = programs.run_program(git, ['rev-parse', '--short', 'HEAD'], + cwd=repopath).communicate() + commit = commit[0].strip() + if PY3: + commit = commit.decode(sys.getdefaultencoding()) + + # Branch + branches = programs.run_program(git, ['branch'], + cwd=repopath).communicate() + branches = branches[0] + if PY3: + branches = branches.decode(sys.getdefaultencoding()) + branches = branches.split('\n') + active_branch = [b for b in branches if b.startswith('*')] + if len(active_branch) != 1: + branch = None + else: + branch = active_branch[0].split(None, 1)[1] + + return commit, branch + except (subprocess.CalledProcessError, AssertionError, AttributeError, + OSError): + return None, None + + +def get_git_refs(repopath): + """ + Return Git active branch, state, branches (plus tags). + """ + tags = [] + branches = [] + branch = '' + files_modifed = [] + + if os.path.isfile(repopath): + repopath = os.path.dirname(repopath) + + git = programs.find_git() + + if git: + try: + # Files modified + out, err = programs.run_program( + git, ['status', '-s'], + cwd=repopath, + ).communicate() + + if PY3: + out = out.decode(sys.getdefaultencoding()) + files_modifed = [line.strip() for line in out.split('\n') if line] + + # Tags + out, err = programs.run_program( + git, ['tag'], + cwd=repopath, + ).communicate() + + if PY3: + out = out.decode(sys.getdefaultencoding()) + tags = [line.strip() for line in out.split('\n') if line] + + # Branches + out, err = programs.run_program( + git, ['branch', '-a'], + cwd=repopath, + ).communicate() + + if PY3: + out = out.decode(sys.getdefaultencoding()) + + lines = [line.strip() for line in out.split('\n') if line] + for line in lines: + if line.startswith('*'): + line = line.replace('*', '').strip() + branch = line + + branches.append(line) + + except (subprocess.CalledProcessError, AttributeError, OSError): + pass + + return branches + tags, branch, files_modifed + + +def get_git_remotes(fpath): + """Return git remotes for repo on fpath.""" + remote_data = {} + data, __ = programs.run_program( + 'git', + ['remote', '-v'], + cwd=osp.dirname(fpath), + ).communicate() + + if PY3: + data = data.decode(sys.getdefaultencoding()) + + lines = [line.strip() for line in data.split('\n') if line] + for line in lines: + if line: + remote, value = line.split('\t') + remote_data[remote] = value.split(' ')[0] + + return remote_data + + +def remote_to_url(remote): + """Convert a git remote to a url.""" + url = '' + if remote.startswith('git@'): + url = remote.replace('git@', '') + url = url.replace(':', '/') + url = 'https://' + url.replace('.git', '') + else: + url = remote.replace('.git', '') + + return url diff --git a/spyder/utils/windows.py b/spyder/utils/windows.py index 0fa92559944..3ce9637ee83 100644 --- a/spyder/utils/windows.py +++ b/spyder/utils/windows.py @@ -1,48 +1,48 @@ -# -*- coding: utf-8 -*- -# -# Copyright © Spyder Project Contributors -# Licensed under the terms of the MIT License -# (see spyder/__init__.py for details) - -"""Windows-specific utilities""" - - -from ctypes import windll - - -# --- Window control --- - -SW_SHOW = 5 # activate and display -SW_SHOWNA = 8 # show without activation -SW_HIDE = 0 - -GetConsoleWindow = windll.kernel32.GetConsoleWindow -ShowWindow = windll.user32.ShowWindow -IsWindowVisible = windll.user32.IsWindowVisible - -# Handle to console window associated with current Python -# interpreter procss, 0 if there is no window -console_window_handle = GetConsoleWindow() - -def set_attached_console_visible(state): - """Show/hide system console window attached to current process. - Return it's previous state. - - Availability: Windows""" - flag = {True: SW_SHOW, False: SW_HIDE} - return bool(ShowWindow(console_window_handle, flag[state])) - -def is_attached_console_visible(): - """Return True if attached console window is visible""" - return IsWindowVisible(console_window_handle) - -def set_windows_appusermodelid(): - """Make sure correct icon is used on Windows 7 taskbar""" - try: - return windll.shell32.SetCurrentProcessExplicitAppUserModelID("spyder.Spyder") - except AttributeError: - return "SetCurrentProcessExplicitAppUserModelID not found" - - -# [ ] the console state asks for a storage container -# [ ] reopen console on exit - better die open than become a zombie +# -*- coding: utf-8 -*- +# +# Copyright © Spyder Project Contributors +# Licensed under the terms of the MIT License +# (see spyder/__init__.py for details) + +"""Windows-specific utilities""" + + +from ctypes import windll + + +# --- Window control --- + +SW_SHOW = 5 # activate and display +SW_SHOWNA = 8 # show without activation +SW_HIDE = 0 + +GetConsoleWindow = windll.kernel32.GetConsoleWindow +ShowWindow = windll.user32.ShowWindow +IsWindowVisible = windll.user32.IsWindowVisible + +# Handle to console window associated with current Python +# interpreter procss, 0 if there is no window +console_window_handle = GetConsoleWindow() + +def set_attached_console_visible(state): + """Show/hide system console window attached to current process. + Return it's previous state. + + Availability: Windows""" + flag = {True: SW_SHOW, False: SW_HIDE} + return bool(ShowWindow(console_window_handle, flag[state])) + +def is_attached_console_visible(): + """Return True if attached console window is visible""" + return IsWindowVisible(console_window_handle) + +def set_windows_appusermodelid(): + """Make sure correct icon is used on Windows 7 taskbar""" + try: + return windll.shell32.SetCurrentProcessExplicitAppUserModelID("spyder.Spyder") + except AttributeError: + return "SetCurrentProcessExplicitAppUserModelID not found" + + +# [ ] the console state asks for a storage container +# [ ] reopen console on exit - better die open than become a zombie diff --git a/spyder/widgets/arraybuilder.py b/spyder/widgets/arraybuilder.py index bb78bd3e5c5..a5c512db3c0 100644 --- a/spyder/widgets/arraybuilder.py +++ b/spyder/widgets/arraybuilder.py @@ -1,423 +1,423 @@ -# -*- coding: utf-8 -*- -# -# Copyright © Spyder Project Contributors -# Licensed under the terms of the MIT License -# (see spyder/__init__.py for details) - -"""Array Builder Widget.""" - -# TODO: -# - Set font based on caller? editor console? and adjust size of widget -# - Fix positioning -# - Use the same font as editor/console? -# - Generalize separators -# - Generalize API for registering new array builders - -# Standard library imports -from __future__ import division -import re - -# Third party imports -from qtpy.QtCore import QEvent, QPoint, Qt -from qtpy.QtWidgets import (QDialog, QHBoxLayout, QLineEdit, QTableWidget, - QTableWidgetItem, QToolButton, QToolTip) - -# Local imports -from spyder.config.base import _ -from spyder.utils.icon_manager import ima -from spyder.utils.palette import QStylePalette -from spyder.widgets.helperwidgets import HelperToolButton - -# Constants -SHORTCUT_TABLE = "Ctrl+M" -SHORTCUT_INLINE = "Ctrl+Alt+M" - - -class ArrayBuilderType: - LANGUAGE = None - ELEMENT_SEPARATOR = None - ROW_SEPARATOR = None - BRACES = None - EXTRA_VALUES = None - ARRAY_PREFIX = None - MATRIX_PREFIX = None - - def check_values(self): - pass - - -class ArrayBuilderPython(ArrayBuilderType): - ELEMENT_SEPARATOR = ', ' - ROW_SEPARATOR = ';' - BRACES = '], [' - EXTRA_VALUES = { - 'np.nan': ['nan', 'NAN', 'NaN', 'Na', 'NA', 'na'], - 'np.inf': ['inf', 'INF'], - } - ARRAY_PREFIX = 'np.array([[' - MATRIX_PREFIX = 'np.matrix([[' - - -_REGISTERED_ARRAY_BUILDERS = { - 'python': ArrayBuilderPython, -} - - -class ArrayInline(QLineEdit): - def __init__(self, parent, options=None): - super(ArrayInline, self).__init__(parent) - self._parent = parent - self._options = options - - def keyPressEvent(self, event): - """Override Qt method.""" - if event.key() in [Qt.Key_Enter, Qt.Key_Return]: - self._parent.process_text() - if self._parent.is_valid(): - self._parent.keyPressEvent(event) - else: - super(ArrayInline, self).keyPressEvent(event) - - # To catch the Tab key event - def event(self, event): - """ - Override Qt method. - - This is needed to be able to intercept the Tab key press event. - """ - if event.type() == QEvent.KeyPress: - if (event.key() == Qt.Key_Tab or event.key() == Qt.Key_Space): - text = self.text() - cursor = self.cursorPosition() - - # Fix to include in "undo/redo" history - if cursor != 0 and text[cursor-1] == ' ': - text = (text[:cursor-1] + self._options.ROW_SEPARATOR - + ' ' + text[cursor:]) - else: - text = text[:cursor] + ' ' + text[cursor:] - self.setCursorPosition(cursor) - self.setText(text) - self.setCursorPosition(cursor + 1) - - return False - - return super(ArrayInline, self).event(event) - - -class ArrayTable(QTableWidget): - def __init__(self, parent, options=None): - super(ArrayTable, self).__init__(parent) - self._parent = parent - self._options = options - self.setRowCount(2) - self.setColumnCount(2) - self.reset_headers() - - # signals - self.cellChanged.connect(self.cell_changed) - - def keyPressEvent(self, event): - """Override Qt method.""" - super(ArrayTable, self).keyPressEvent(event) - if event.key() in [Qt.Key_Enter, Qt.Key_Return]: - # To avoid having to enter one final tab - self.setDisabled(True) - self.setDisabled(False) - self._parent.keyPressEvent(event) - - def cell_changed(self, row, col): - item = self.item(row, col) - value = None - - if item: - rows = self.rowCount() - cols = self.columnCount() - value = item.text() - - if value: - if row == rows - 1: - self.setRowCount(rows + 1) - if col == cols - 1: - self.setColumnCount(cols + 1) - self.reset_headers() - - def reset_headers(self): - """Update the column and row numbering in the headers.""" - rows = self.rowCount() - cols = self.columnCount() - - for r in range(rows): - self.setVerticalHeaderItem(r, QTableWidgetItem(str(r))) - for c in range(cols): - self.setHorizontalHeaderItem(c, QTableWidgetItem(str(c))) - self.setColumnWidth(c, 40) - - def text(self): - """Return the entered array in a parseable form.""" - text = [] - rows = self.rowCount() - cols = self.columnCount() - - # handle empty table case - if rows == 2 and cols == 2: - item = self.item(0, 0) - if item is None: - return '' - - for r in range(rows - 1): - for c in range(cols - 1): - item = self.item(r, c) - if item is not None: - value = item.text() - else: - value = '0' - - if not value.strip(): - value = '0' - - text.append(' ') - text.append(value) - text.append(self._options.ROW_SEPARATOR) - - return ''.join(text[:-1]) # Remove the final uneeded `;` - - -class ArrayBuilderDialog(QDialog): - def __init__(self, parent=None, inline=True, offset=0, force_float=False, - language='python'): - super(ArrayBuilderDialog, self).__init__(parent=parent) - self._language = language - self._options = _REGISTERED_ARRAY_BUILDERS.get('python', None) - self._parent = parent - self._text = None - self._valid = None - self._offset = offset - - # TODO: add this as an option in the General Preferences? - self._force_float = force_float - - self._help_inline = _(""" - Numpy Array/Matrix Helper
    - Type an array in Matlab : [1 2;3 4]
    - or Spyder simplified syntax : 1 2;3 4 -

    - Hit 'Enter' for array or 'Ctrl+Enter' for matrix. -

    - Hint:
    - Use two spaces or two tabs to generate a ';'. - """) - - self._help_table = _(""" - Numpy Array/Matrix Helper
    - Enter an array in the table.
    - Use Tab to move between cells. -

    - Hit 'Enter' for array or 'Ctrl+Enter' for matrix. -

    - Hint:
    - Use two tabs at the end of a row to move to the next row. - """) - - # Widgets - self._button_warning = QToolButton() - self._button_help = HelperToolButton() - self._button_help.setIcon(ima.icon('MessageBoxInformation')) - - style = ((""" - QToolButton {{ - border: 1px solid grey; - padding:0px; - border-radius: 2px; - background-color: qlineargradient(x1: 1, y1: 1, x2: 1, y2: 1, - stop: 0 {stop_0}, stop: 1 {stop_1}); - }} - """).format(stop_0=QStylePalette.COLOR_BACKGROUND_4, - stop_1=QStylePalette.COLOR_BACKGROUND_2)) - - self._button_help.setStyleSheet(style) - - if inline: - self._button_help.setToolTip(self._help_inline) - self._text = ArrayInline(self, options=self._options) - self._widget = self._text - else: - self._button_help.setToolTip(self._help_table) - self._table = ArrayTable(self, options=self._options) - self._widget = self._table - - style = """ - QDialog { - margin:0px; - border: 1px solid grey; - padding:0px; - border-radius: 2px; - }""" - self.setStyleSheet(style) - - style = """ - QToolButton { - margin:1px; - border: 0px solid grey; - padding:0px; - border-radius: 0px; - }""" - self._button_warning.setStyleSheet(style) - - # widget setup - self.setWindowFlags(Qt.Window | Qt.Dialog | Qt.FramelessWindowHint) - self.setModal(True) - self.setWindowOpacity(0.90) - self._widget.setMinimumWidth(200) - - # layout - self._layout = QHBoxLayout() - self._layout.addWidget(self._widget) - self._layout.addWidget(self._button_warning, 1, Qt.AlignTop) - self._layout.addWidget(self._button_help, 1, Qt.AlignTop) - self.setLayout(self._layout) - - self._widget.setFocus() - - def keyPressEvent(self, event): - """Override Qt method.""" - QToolTip.hideText() - ctrl = event.modifiers() & Qt.ControlModifier - - if event.key() in [Qt.Key_Enter, Qt.Key_Return]: - if ctrl: - self.process_text(array=False) - else: - self.process_text(array=True) - self.accept() - else: - super(ArrayBuilderDialog, self).keyPressEvent(event) - - def event(self, event): - """ - Override Qt method. - - Useful when in line edit mode. - """ - if event.type() == QEvent.KeyPress and event.key() == Qt.Key_Tab: - return False - - return super(ArrayBuilderDialog, self).event(event) - - def process_text(self, array=True): - """ - Construct the text based on the entered content in the widget. - """ - if array: - prefix = self._options.ARRAY_PREFIX - else: - prefix = self._options.MATRIX_PREFIX - - suffix = ']])' - values = self._widget.text().strip() - - if values != '': - # cleans repeated spaces - exp = r'(\s*)' + self._options.ROW_SEPARATOR + r'(\s*)' - values = re.sub(exp, self._options.ROW_SEPARATOR, values) - values = re.sub(r"\s+", " ", values) - values = re.sub(r"]$", "", values) - values = re.sub(r"^\[", "", values) - values = re.sub(self._options.ROW_SEPARATOR + r'*$', '', values) - - # replaces spaces by commas - values = values.replace(' ', self._options.ELEMENT_SEPARATOR) - - # iterate to find number of rows and columns - new_values = [] - rows = values.split(self._options.ROW_SEPARATOR) - nrows = len(rows) - ncols = [] - for row in rows: - new_row = [] - elements = row.split(self._options.ELEMENT_SEPARATOR) - ncols.append(len(elements)) - for e in elements: - num = e - - # replaces not defined values - for key, values in self._options.EXTRA_VALUES.items(): - if num in values: - num = key - - # Convert numbers to floating point - if self._force_float: - try: - num = str(float(e)) - except: - pass - new_row.append(num) - new_values.append( - self._options.ELEMENT_SEPARATOR.join(new_row)) - new_values = self._options.ROW_SEPARATOR.join(new_values) - values = new_values - - # Check validity - if len(set(ncols)) == 1: - self._valid = True - else: - self._valid = False - - # Single rows are parsed as 1D arrays/matrices - if nrows == 1: - prefix = prefix[:-1] - suffix = suffix.replace("]])", "])") - - # Fix offset - offset = self._offset - braces = self._options.BRACES.replace( - ' ', - '\n' + ' '*(offset + len(prefix) - 1)) - values = values.replace(self._options.ROW_SEPARATOR, braces) - text = "{0}{1}{2}".format(prefix, values, suffix) - - self._text = text - else: - self._text = '' - - self.update_warning() - - def update_warning(self): - """ - Updates the icon and tip based on the validity of the array content. - """ - widget = self._button_warning - if not self.is_valid(): - tip = _('Array dimensions not valid') - widget.setIcon(ima.icon('MessageBoxWarning')) - widget.setToolTip(tip) - QToolTip.showText(self._widget.mapToGlobal(QPoint(0, 5)), tip) - else: - self._button_warning.setToolTip('') - - def is_valid(self): - """Return if the current array state is valid.""" - return self._valid - - def text(self): - """Return the parsed array/matrix text.""" - return self._text - - @property - def array_widget(self): - """Return the array builder widget.""" - return self._widget - - -def test(): # pragma: no cover - from spyder.utils.qthelpers import qapplication - app = qapplication() - dlg_table = ArrayBuilderDialog(None, inline=False) - dlg_inline = ArrayBuilderDialog(None, inline=True) - dlg_table.show() - dlg_inline.show() - app.exec_() - - -if __name__ == "__main__": # pragma: no cover - test() +# -*- coding: utf-8 -*- +# +# Copyright © Spyder Project Contributors +# Licensed under the terms of the MIT License +# (see spyder/__init__.py for details) + +"""Array Builder Widget.""" + +# TODO: +# - Set font based on caller? editor console? and adjust size of widget +# - Fix positioning +# - Use the same font as editor/console? +# - Generalize separators +# - Generalize API for registering new array builders + +# Standard library imports +from __future__ import division +import re + +# Third party imports +from qtpy.QtCore import QEvent, QPoint, Qt +from qtpy.QtWidgets import (QDialog, QHBoxLayout, QLineEdit, QTableWidget, + QTableWidgetItem, QToolButton, QToolTip) + +# Local imports +from spyder.config.base import _ +from spyder.utils.icon_manager import ima +from spyder.utils.palette import QStylePalette +from spyder.widgets.helperwidgets import HelperToolButton + +# Constants +SHORTCUT_TABLE = "Ctrl+M" +SHORTCUT_INLINE = "Ctrl+Alt+M" + + +class ArrayBuilderType: + LANGUAGE = None + ELEMENT_SEPARATOR = None + ROW_SEPARATOR = None + BRACES = None + EXTRA_VALUES = None + ARRAY_PREFIX = None + MATRIX_PREFIX = None + + def check_values(self): + pass + + +class ArrayBuilderPython(ArrayBuilderType): + ELEMENT_SEPARATOR = ', ' + ROW_SEPARATOR = ';' + BRACES = '], [' + EXTRA_VALUES = { + 'np.nan': ['nan', 'NAN', 'NaN', 'Na', 'NA', 'na'], + 'np.inf': ['inf', 'INF'], + } + ARRAY_PREFIX = 'np.array([[' + MATRIX_PREFIX = 'np.matrix([[' + + +_REGISTERED_ARRAY_BUILDERS = { + 'python': ArrayBuilderPython, +} + + +class ArrayInline(QLineEdit): + def __init__(self, parent, options=None): + super(ArrayInline, self).__init__(parent) + self._parent = parent + self._options = options + + def keyPressEvent(self, event): + """Override Qt method.""" + if event.key() in [Qt.Key_Enter, Qt.Key_Return]: + self._parent.process_text() + if self._parent.is_valid(): + self._parent.keyPressEvent(event) + else: + super(ArrayInline, self).keyPressEvent(event) + + # To catch the Tab key event + def event(self, event): + """ + Override Qt method. + + This is needed to be able to intercept the Tab key press event. + """ + if event.type() == QEvent.KeyPress: + if (event.key() == Qt.Key_Tab or event.key() == Qt.Key_Space): + text = self.text() + cursor = self.cursorPosition() + + # Fix to include in "undo/redo" history + if cursor != 0 and text[cursor-1] == ' ': + text = (text[:cursor-1] + self._options.ROW_SEPARATOR + + ' ' + text[cursor:]) + else: + text = text[:cursor] + ' ' + text[cursor:] + self.setCursorPosition(cursor) + self.setText(text) + self.setCursorPosition(cursor + 1) + + return False + + return super(ArrayInline, self).event(event) + + +class ArrayTable(QTableWidget): + def __init__(self, parent, options=None): + super(ArrayTable, self).__init__(parent) + self._parent = parent + self._options = options + self.setRowCount(2) + self.setColumnCount(2) + self.reset_headers() + + # signals + self.cellChanged.connect(self.cell_changed) + + def keyPressEvent(self, event): + """Override Qt method.""" + super(ArrayTable, self).keyPressEvent(event) + if event.key() in [Qt.Key_Enter, Qt.Key_Return]: + # To avoid having to enter one final tab + self.setDisabled(True) + self.setDisabled(False) + self._parent.keyPressEvent(event) + + def cell_changed(self, row, col): + item = self.item(row, col) + value = None + + if item: + rows = self.rowCount() + cols = self.columnCount() + value = item.text() + + if value: + if row == rows - 1: + self.setRowCount(rows + 1) + if col == cols - 1: + self.setColumnCount(cols + 1) + self.reset_headers() + + def reset_headers(self): + """Update the column and row numbering in the headers.""" + rows = self.rowCount() + cols = self.columnCount() + + for r in range(rows): + self.setVerticalHeaderItem(r, QTableWidgetItem(str(r))) + for c in range(cols): + self.setHorizontalHeaderItem(c, QTableWidgetItem(str(c))) + self.setColumnWidth(c, 40) + + def text(self): + """Return the entered array in a parseable form.""" + text = [] + rows = self.rowCount() + cols = self.columnCount() + + # handle empty table case + if rows == 2 and cols == 2: + item = self.item(0, 0) + if item is None: + return '' + + for r in range(rows - 1): + for c in range(cols - 1): + item = self.item(r, c) + if item is not None: + value = item.text() + else: + value = '0' + + if not value.strip(): + value = '0' + + text.append(' ') + text.append(value) + text.append(self._options.ROW_SEPARATOR) + + return ''.join(text[:-1]) # Remove the final uneeded `;` + + +class ArrayBuilderDialog(QDialog): + def __init__(self, parent=None, inline=True, offset=0, force_float=False, + language='python'): + super(ArrayBuilderDialog, self).__init__(parent=parent) + self._language = language + self._options = _REGISTERED_ARRAY_BUILDERS.get('python', None) + self._parent = parent + self._text = None + self._valid = None + self._offset = offset + + # TODO: add this as an option in the General Preferences? + self._force_float = force_float + + self._help_inline = _(""" + Numpy Array/Matrix Helper
    + Type an array in Matlab : [1 2;3 4]
    + or Spyder simplified syntax : 1 2;3 4 +

    + Hit 'Enter' for array or 'Ctrl+Enter' for matrix. +

    + Hint:
    + Use two spaces or two tabs to generate a ';'. + """) + + self._help_table = _(""" + Numpy Array/Matrix Helper
    + Enter an array in the table.
    + Use Tab to move between cells. +

    + Hit 'Enter' for array or 'Ctrl+Enter' for matrix. +

    + Hint:
    + Use two tabs at the end of a row to move to the next row. + """) + + # Widgets + self._button_warning = QToolButton() + self._button_help = HelperToolButton() + self._button_help.setIcon(ima.icon('MessageBoxInformation')) + + style = ((""" + QToolButton {{ + border: 1px solid grey; + padding:0px; + border-radius: 2px; + background-color: qlineargradient(x1: 1, y1: 1, x2: 1, y2: 1, + stop: 0 {stop_0}, stop: 1 {stop_1}); + }} + """).format(stop_0=QStylePalette.COLOR_BACKGROUND_4, + stop_1=QStylePalette.COLOR_BACKGROUND_2)) + + self._button_help.setStyleSheet(style) + + if inline: + self._button_help.setToolTip(self._help_inline) + self._text = ArrayInline(self, options=self._options) + self._widget = self._text + else: + self._button_help.setToolTip(self._help_table) + self._table = ArrayTable(self, options=self._options) + self._widget = self._table + + style = """ + QDialog { + margin:0px; + border: 1px solid grey; + padding:0px; + border-radius: 2px; + }""" + self.setStyleSheet(style) + + style = """ + QToolButton { + margin:1px; + border: 0px solid grey; + padding:0px; + border-radius: 0px; + }""" + self._button_warning.setStyleSheet(style) + + # widget setup + self.setWindowFlags(Qt.Window | Qt.Dialog | Qt.FramelessWindowHint) + self.setModal(True) + self.setWindowOpacity(0.90) + self._widget.setMinimumWidth(200) + + # layout + self._layout = QHBoxLayout() + self._layout.addWidget(self._widget) + self._layout.addWidget(self._button_warning, 1, Qt.AlignTop) + self._layout.addWidget(self._button_help, 1, Qt.AlignTop) + self.setLayout(self._layout) + + self._widget.setFocus() + + def keyPressEvent(self, event): + """Override Qt method.""" + QToolTip.hideText() + ctrl = event.modifiers() & Qt.ControlModifier + + if event.key() in [Qt.Key_Enter, Qt.Key_Return]: + if ctrl: + self.process_text(array=False) + else: + self.process_text(array=True) + self.accept() + else: + super(ArrayBuilderDialog, self).keyPressEvent(event) + + def event(self, event): + """ + Override Qt method. + + Useful when in line edit mode. + """ + if event.type() == QEvent.KeyPress and event.key() == Qt.Key_Tab: + return False + + return super(ArrayBuilderDialog, self).event(event) + + def process_text(self, array=True): + """ + Construct the text based on the entered content in the widget. + """ + if array: + prefix = self._options.ARRAY_PREFIX + else: + prefix = self._options.MATRIX_PREFIX + + suffix = ']])' + values = self._widget.text().strip() + + if values != '': + # cleans repeated spaces + exp = r'(\s*)' + self._options.ROW_SEPARATOR + r'(\s*)' + values = re.sub(exp, self._options.ROW_SEPARATOR, values) + values = re.sub(r"\s+", " ", values) + values = re.sub(r"]$", "", values) + values = re.sub(r"^\[", "", values) + values = re.sub(self._options.ROW_SEPARATOR + r'*$', '', values) + + # replaces spaces by commas + values = values.replace(' ', self._options.ELEMENT_SEPARATOR) + + # iterate to find number of rows and columns + new_values = [] + rows = values.split(self._options.ROW_SEPARATOR) + nrows = len(rows) + ncols = [] + for row in rows: + new_row = [] + elements = row.split(self._options.ELEMENT_SEPARATOR) + ncols.append(len(elements)) + for e in elements: + num = e + + # replaces not defined values + for key, values in self._options.EXTRA_VALUES.items(): + if num in values: + num = key + + # Convert numbers to floating point + if self._force_float: + try: + num = str(float(e)) + except: + pass + new_row.append(num) + new_values.append( + self._options.ELEMENT_SEPARATOR.join(new_row)) + new_values = self._options.ROW_SEPARATOR.join(new_values) + values = new_values + + # Check validity + if len(set(ncols)) == 1: + self._valid = True + else: + self._valid = False + + # Single rows are parsed as 1D arrays/matrices + if nrows == 1: + prefix = prefix[:-1] + suffix = suffix.replace("]])", "])") + + # Fix offset + offset = self._offset + braces = self._options.BRACES.replace( + ' ', + '\n' + ' '*(offset + len(prefix) - 1)) + values = values.replace(self._options.ROW_SEPARATOR, braces) + text = "{0}{1}{2}".format(prefix, values, suffix) + + self._text = text + else: + self._text = '' + + self.update_warning() + + def update_warning(self): + """ + Updates the icon and tip based on the validity of the array content. + """ + widget = self._button_warning + if not self.is_valid(): + tip = _('Array dimensions not valid') + widget.setIcon(ima.icon('MessageBoxWarning')) + widget.setToolTip(tip) + QToolTip.showText(self._widget.mapToGlobal(QPoint(0, 5)), tip) + else: + self._button_warning.setToolTip('') + + def is_valid(self): + """Return if the current array state is valid.""" + return self._valid + + def text(self): + """Return the parsed array/matrix text.""" + return self._text + + @property + def array_widget(self): + """Return the array builder widget.""" + return self._widget + + +def test(): # pragma: no cover + from spyder.utils.qthelpers import qapplication + app = qapplication() + dlg_table = ArrayBuilderDialog(None, inline=False) + dlg_inline = ArrayBuilderDialog(None, inline=True) + dlg_table.show() + dlg_inline.show() + app.exec_() + + +if __name__ == "__main__": # pragma: no cover + test() diff --git a/spyder/widgets/browser.py b/spyder/widgets/browser.py index 5049e2e389c..c765c29347b 100644 --- a/spyder/widgets/browser.py +++ b/spyder/widgets/browser.py @@ -1,616 +1,616 @@ -# -*- coding: utf-8 -*- -# -# Copyright © Spyder Project Contributors -# Licensed under the terms of the MIT License -# (see spyder/__init__.py for details) - -"""Simple web browser widget""" - -# Standard library imports -import re -import sre_constants -import sys - -# Third party imports -import qstylizer.style -from qtpy import PYQT5 -from qtpy.QtCore import QEvent, Qt, QUrl, Signal, Slot -from qtpy.QtGui import QFontInfo -from qtpy.QtWebEngineWidgets import (WEBENGINE, QWebEnginePage, - QWebEngineSettings, QWebEngineView) -from qtpy.QtWidgets import QFrame, QHBoxLayout, QLabel, QProgressBar, QWidget - -# Local imports -from spyder.api.translations import get_translation -from spyder.api.widgets.mixins import SpyderWidgetMixin -from spyder.config.base import DEV -from spyder.config.gui import OLD_PYQT -from spyder.py3compat import is_text_string, to_text_string -from spyder.utils.icon_manager import ima -from spyder.utils.palette import QStylePalette -from spyder.utils.qthelpers import (action2button, create_plugin_layout, - create_toolbutton) -from spyder.widgets.comboboxes import UrlComboBox -from spyder.widgets.findreplace import FindReplace - - -# Localization -_ = get_translation('spyder') - - -# --- Constants -# ---------------------------------------------------------------------------- -class WebViewActions: - ZoomIn = 'zoom_in_action' - ZoomOut = 'zoom_out_action' - Back = 'back_action' - Forward = 'forward_action' - SelectAll = 'select_all_action' - Copy = 'copy_action' - Inspect = 'inspect_action' - Stop = 'stop_action' - Refresh = 'refresh_action' - - -class WebViewMenuSections: - Move = 'move_section' - Select = 'select_section' - Zoom = 'zoom_section' - Extras = 'extras_section' - - -class WebViewMenus: - Context = 'context_menu' - - -# --- Widgets -# ---------------------------------------------------------------------------- -class WebPage(QWebEnginePage): - """ - Web page subclass to manage hyperlinks for WebEngine - - Note: This can't be used for WebKit because the - acceptNavigationRequest method has a different - functionality for it. - """ - linkClicked = Signal(QUrl) - - def acceptNavigationRequest(self, url, navigation_type, isMainFrame): - """ - Overloaded method to handle links ourselves - """ - if navigation_type == QWebEnginePage.NavigationTypeLinkClicked: - self.linkClicked.emit(url) - return False - - return super(WebPage, self).acceptNavigationRequest( - url, navigation_type, isMainFrame) - - -class WebView(QWebEngineView, SpyderWidgetMixin): - """ - Web view. - """ - sig_focus_in_event = Signal() - """ - This signal is emitted when the widget receives focus. - """ - - sig_focus_out_event = Signal() - """ - This signal is emitted when the widget loses focus. - """ - - def __init__(self, parent, handle_links=True, class_parent=None): - class_parent = parent if class_parent is None else class_parent - if PYQT5: - super().__init__(parent, class_parent=class_parent) - else: - QWebEngineView.__init__(self, parent) - SpyderWidgetMixin.__init__(self, class_parent=class_parent) - - self.zoom_factor = 1. - self.context_menu = None - - if WEBENGINE: - if handle_links: - web_page = WebPage(self) - else: - web_page = QWebEnginePage(self) - - self.setPage(web_page) - self.source_text = '' - - def setup(self, options={}): - # Actions - original_back_action = self.pageAction(QWebEnginePage.Back) - back_action = self.create_action( - name=WebViewActions.Back, - text=_("Back"), - icon=self.create_icon('previous'), - triggered=lambda: original_back_action.trigger(), - context=Qt.WidgetWithChildrenShortcut, - ) - - original_forward_action = self.pageAction(QWebEnginePage.Forward) - forward_action = self.create_action( - name=WebViewActions.Forward, - text=_("Forward"), - icon=self.create_icon('next'), - triggered=lambda: original_forward_action.trigger(), - context=Qt.WidgetWithChildrenShortcut, - ) - - original_select_action = self.pageAction(QWebEnginePage.SelectAll) - select_all_action = self.create_action( - name=WebViewActions.SelectAll, - text=_("Select all"), - triggered=lambda: original_select_action.trigger(), - context=Qt.WidgetWithChildrenShortcut, - ) - - original_copy_action = self.pageAction(QWebEnginePage.Copy) - copy_action = self.create_action( - name=WebViewActions.Copy, - text=_("Copy"), - triggered=lambda: original_copy_action.trigger(), - context=Qt.WidgetWithChildrenShortcut, - ) - - self.zoom_in_action = self.create_action( - name=WebViewActions.ZoomIn, - text=_("Zoom in"), - icon=self.create_icon('zoom_in'), - triggered=self.zoom_in, - context=Qt.WidgetWithChildrenShortcut, - ) - - self.zoom_out_action = self.create_action( - name=WebViewActions.ZoomOut, - text=_("Zoom out"), - icon=self.create_icon('zoom_out'), - triggered=self.zoom_out, - context=Qt.WidgetWithChildrenShortcut, - ) - - original_inspect_action = self.pageAction( - QWebEnginePage.InspectElement) - inspect_action = self.create_action( - name=WebViewActions.Inspect, - text=_("Inspect"), - triggered=lambda: original_inspect_action.trigger(), - context=Qt.WidgetWithChildrenShortcut, - ) - - original_refresh_action = self.pageAction(QWebEnginePage.Reload) - self.create_action( - name=WebViewActions.Refresh, - text=_("Refresh"), - icon=self.create_icon('refresh'), - triggered=lambda: original_refresh_action.trigger(), - context=Qt.WidgetWithChildrenShortcut, - ) - - original_stop_action = self.pageAction(QWebEnginePage.Stop) - self.create_action( - name=WebViewActions.Stop, - text=_("Stop"), - icon=self.create_icon('stop'), - triggered=lambda: original_stop_action.trigger(), - context=Qt.WidgetWithChildrenShortcut, - ) - - menu = self.create_menu(WebViewMenus.Context) - self.context_menu = menu - for item in [back_action, forward_action]: - self.add_item_to_menu( - item, - menu=menu, - section=WebViewMenuSections.Move, - ) - - for item in [select_all_action, copy_action]: - self.add_item_to_menu( - item, - menu=menu, - section=WebViewMenuSections.Select, - ) - - for item in [self.zoom_in_action, self.zoom_out_action]: - self.add_item_to_menu( - item, - menu=menu, - section=WebViewMenuSections.Zoom, - ) - - self.add_item_to_menu( - inspect_action, - menu=menu, - section=WebViewMenuSections.Extras, - ) - - if DEV and not WEBENGINE: - settings = self.page().settings() - settings.setAttribute(QWebEngineSettings.DeveloperExtrasEnabled, - True) - inspect_action.setVisible(True) - else: - inspect_action.setVisible(False) - - def find_text(self, text, changed=True, forward=True, case=False, - word=False, regexp=False): - """Find text.""" - if not WEBENGINE: - findflag = QWebEnginePage.FindWrapsAroundDocument - else: - findflag = 0 - - if not forward: - findflag = findflag | QWebEnginePage.FindBackward - if case: - findflag = findflag | QWebEnginePage.FindCaseSensitively - - return self.findText(text, QWebEnginePage.FindFlags(findflag)) - - def get_selected_text(self): - """Return text selected by current text cursor""" - return self.selectedText() - - def set_source_text(self, source_text): - """Set source text of the page. Callback for QWebEngineView.""" - self.source_text = source_text - - def get_number_matches(self, pattern, source_text='', case=False, - regexp=False, word=False): - """Get the number of matches for the searched text.""" - pattern = to_text_string(pattern) - if not pattern: - return 0 - if not regexp: - pattern = re.escape(pattern) - if not source_text: - if WEBENGINE: - self.page().toPlainText(self.set_source_text) - source_text = to_text_string(self.source_text) - else: - source_text = to_text_string( - self.page().mainFrame().toPlainText()) - - if word: # match whole words only - pattern = r'\b{pattern}\b'.format(pattern=pattern) - - try: - if case: - regobj = re.compile(pattern, re.MULTILINE) - else: - regobj = re.compile(pattern, re.MULTILINE | re.IGNORECASE) - except sre_constants.error: - return - - number_matches = 0 - for match in regobj.finditer(source_text): - number_matches += 1 - - return number_matches - - def set_font(self, font, fixed_font=None): - font = QFontInfo(font) - settings = self.page().settings() - for fontfamily in (settings.StandardFont, settings.SerifFont, - settings.SansSerifFont, settings.CursiveFont, - settings.FantasyFont): - settings.setFontFamily(fontfamily, font.family()) - if fixed_font is not None: - settings.setFontFamily(settings.FixedFont, fixed_font.family()) - size = font.pixelSize() - settings.setFontSize(settings.DefaultFontSize, size) - settings.setFontSize(settings.DefaultFixedFontSize, size) - - def apply_zoom_factor(self): - """Apply zoom factor.""" - if hasattr(self, 'setZoomFactor'): - # Assuming Qt >=v4.5 - self.setZoomFactor(self.zoom_factor) - else: - # Qt v4.4 - self.setTextSizeMultiplier(self.zoom_factor) - - def set_zoom_factor(self, zoom_factor): - """Set zoom factor.""" - self.zoom_factor = zoom_factor - self.apply_zoom_factor() - - def get_zoom_factor(self): - """Return zoom factor.""" - return self.zoom_factor - - @Slot() - def zoom_out(self): - """Zoom out.""" - self.zoom_factor = max(.1, self.zoom_factor-.1) - self.apply_zoom_factor() - - @Slot() - def zoom_in(self): - """Zoom in.""" - self.zoom_factor += .1 - self.apply_zoom_factor() - - #------ QWebEngineView API ------------------------------------------------------- - def createWindow(self, webwindowtype): - import webbrowser - # See: spyder-ide/spyder#9849 - try: - webbrowser.open(to_text_string(self.url().toString())) - except ValueError: - pass - - def contextMenuEvent(self, event): - if self.context_menu: - self.context_menu.popup(event.globalPos()) - event.accept() - - def setHtml(self, html, baseUrl=QUrl()): - """ - Reimplement Qt method to prevent WebEngine to steal focus - when setting html on the page - - Solution taken from - https://bugreports.qt.io/browse/QTBUG-52999 - """ - if WEBENGINE: - if OLD_PYQT: - self.setEnabled(False) - super(WebView, self).setHtml(html, baseUrl) - if OLD_PYQT: - self.setEnabled(True) - else: - super(WebView, self).setHtml(html, baseUrl) - - # This is required to catch an error with PyQt 5.9, for which - # it seems this functionality is not working. - # Fixes spyder-ide/spyder#16703 - try: - # The event filter needs to be installed every time html is set - # because the proxy changes with new content. - self.focusProxy().installEventFilter(self) - except AttributeError: - pass - - def load(self, url): - """ - Load url. - - This is reimplemented to install our event filter after the - url is loaded. - """ - super().load(url) - self.focusProxy().installEventFilter(self) - - def eventFilter(self, widget, event): - """ - Handle events that affect the view. - - All events (e.g. focus in/out) reach the focus proxy, not this - widget itself. That's why this event filter is necessary. - """ - if self.focusProxy() is widget: - if event.type() == QEvent.FocusIn: - self.sig_focus_in_event.emit() - elif event.type() == QEvent.FocusOut: - self.sig_focus_out_event.emit() - return super().eventFilter(widget, event) - - -class WebBrowser(QWidget): - """ - Web browser widget. - """ - def __init__(self, parent=None, options_button=None, handle_links=True): - QWidget.__init__(self, parent) - - self.home_url = None - - self.webview = WebView(self, handle_links=handle_links) - self.webview.setup() - self.webview.loadFinished.connect(self.load_finished) - self.webview.titleChanged.connect(self.setWindowTitle) - self.webview.urlChanged.connect(self.url_changed) - - home_button = create_toolbutton(self, icon=ima.icon('home'), - tip=_("Home"), - triggered=self.go_home) - - zoom_out_button = action2button(self.webview.zoom_out_action) - zoom_in_button = action2button(self.webview.zoom_in_action) - - def pageact2btn(prop, icon=None): - return action2button( - self.webview.pageAction(prop), parent=self.webview, icon=icon) - - refresh_button = pageact2btn( - QWebEnginePage.Reload, icon=ima.icon('refresh')) - stop_button = pageact2btn( - QWebEnginePage.Stop, icon=ima.icon('stop')) - previous_button = pageact2btn( - QWebEnginePage.Back, icon=ima.icon('previous')) - next_button = pageact2btn( - QWebEnginePage.Forward, icon=ima.icon('next')) - - stop_button.setEnabled(False) - self.webview.loadStarted.connect(lambda: stop_button.setEnabled(True)) - self.webview.loadFinished.connect(lambda: stop_button.setEnabled(False)) - - progressbar = QProgressBar(self) - progressbar.setTextVisible(False) - progressbar.hide() - self.webview.loadStarted.connect(progressbar.show) - self.webview.loadProgress.connect(progressbar.setValue) - self.webview.loadFinished.connect(lambda _state: progressbar.hide()) - - label = QLabel(self.get_label()) - - self.url_combo = UrlComboBox(self) - self.url_combo.valid.connect(self.url_combo_activated) - if not WEBENGINE: - self.webview.iconChanged.connect(self.icon_changed) - - self.find_widget = FindReplace(self) - self.find_widget.set_editor(self.webview) - self.find_widget.hide() - - find_button = create_toolbutton(self, icon=ima.icon('find'), - tip=_("Find text"), - toggled=self.toggle_find_widget) - self.find_widget.visibility_changed.connect(find_button.setChecked) - - hlayout = QHBoxLayout() - for widget in (previous_button, next_button, home_button, find_button, - label, self.url_combo, zoom_out_button, zoom_in_button, - refresh_button, progressbar, stop_button): - hlayout.addWidget(widget) - - if options_button: - hlayout.addWidget(options_button) - - layout = create_plugin_layout(hlayout) - layout.addWidget(self.webview) - layout.addWidget(self.find_widget) - self.setLayout(layout) - - def get_label(self): - """Return address label text""" - return _("Address:") - - def set_home_url(self, text): - """Set home URL""" - self.home_url = QUrl(text) - - def set_url(self, url): - """Set current URL""" - self.url_changed(url) - self.go_to(url) - - def go_to(self, url_or_text): - """Go to page *address*""" - if is_text_string(url_or_text): - url = QUrl(url_or_text) - else: - url = url_or_text - self.webview.load(url) - - @Slot() - def go_home(self): - """Go to home page""" - if self.home_url is not None: - self.set_url(self.home_url) - - def text_to_url(self, text): - """Convert text address into QUrl object""" - return QUrl(text) - - def url_combo_activated(self, valid): - """Load URL from combo box first item""" - text = to_text_string(self.url_combo.currentText()) - self.go_to(self.text_to_url(text)) - - def load_finished(self, ok): - if not ok: - self.webview.setHtml(_("Unable to load page")) - - def url_to_text(self, url): - """Convert QUrl object to displayed text in combo box""" - return url.toString() - - def url_changed(self, url): - """Displayed URL has changed -> updating URL combo box""" - self.url_combo.add_text(self.url_to_text(url)) - - def icon_changed(self): - self.url_combo.setItemIcon(self.url_combo.currentIndex(), - self.webview.icon()) - self.setWindowIcon(self.webview.icon()) - - @Slot(bool) - def toggle_find_widget(self, state): - if state: - self.find_widget.show() - else: - self.find_widget.hide() - - -class FrameWebView(QFrame): - """ - Framed WebView for UI consistency in Spyder. - """ - linkClicked = Signal(QUrl) - - def __init__(self, parent, handle_links=True): - super().__init__(parent) - - self._webview = WebView( - self, - handle_links=handle_links, - class_parent=parent - ) - self._webview.sig_focus_in_event.connect( - lambda: self._apply_stylesheet(focus=True)) - self._webview.sig_focus_out_event.connect( - lambda: self._apply_stylesheet(focus=False)) - - layout = QHBoxLayout() - layout.addWidget(self._webview) - layout.setContentsMargins(0, 0, 0, 0) - self.setLayout(layout) - self._apply_stylesheet() - - if handle_links: - if WEBENGINE: - self._webview.page().linkClicked.connect(self.linkClicked) - else: - self._webview.linkClicked.connect(self.linkClicked) - - def __getattr__(self, name): - if name == '_webview': - return super().__getattr__(name) - - if hasattr(self._webview, name): - return getattr(self._webview, name) - else: - return super().__getattr__(name) - - @property - def web_widget(self): - return self._webview - - def _apply_stylesheet(self, focus=False): - """Apply stylesheet according to the current focus.""" - if focus: - border_color = QStylePalette.COLOR_ACCENT_3 - else: - border_color = QStylePalette.COLOR_BACKGROUND_4 - - css = qstylizer.style.StyleSheet() - css.QFrame.setValues( - border=f'1px solid {border_color}', - margin='0px 1px 0px 1px', - padding='0px 0px 1px 0px', - borderRadius='3px' - ) - - self.setStyleSheet(css.toString()) - - -def test(): - """Run web browser""" - from spyder.utils.qthelpers import qapplication - app = qapplication(test_time=8) - widget = WebBrowser() - widget.show() - widget.set_home_url('https://www.google.com/') - widget.go_home() - sys.exit(app.exec_()) - - -if __name__ == '__main__': - test() +# -*- coding: utf-8 -*- +# +# Copyright © Spyder Project Contributors +# Licensed under the terms of the MIT License +# (see spyder/__init__.py for details) + +"""Simple web browser widget""" + +# Standard library imports +import re +import sre_constants +import sys + +# Third party imports +import qstylizer.style +from qtpy import PYQT5 +from qtpy.QtCore import QEvent, Qt, QUrl, Signal, Slot +from qtpy.QtGui import QFontInfo +from qtpy.QtWebEngineWidgets import (WEBENGINE, QWebEnginePage, + QWebEngineSettings, QWebEngineView) +from qtpy.QtWidgets import QFrame, QHBoxLayout, QLabel, QProgressBar, QWidget + +# Local imports +from spyder.api.translations import get_translation +from spyder.api.widgets.mixins import SpyderWidgetMixin +from spyder.config.base import DEV +from spyder.config.gui import OLD_PYQT +from spyder.py3compat import is_text_string, to_text_string +from spyder.utils.icon_manager import ima +from spyder.utils.palette import QStylePalette +from spyder.utils.qthelpers import (action2button, create_plugin_layout, + create_toolbutton) +from spyder.widgets.comboboxes import UrlComboBox +from spyder.widgets.findreplace import FindReplace + + +# Localization +_ = get_translation('spyder') + + +# --- Constants +# ---------------------------------------------------------------------------- +class WebViewActions: + ZoomIn = 'zoom_in_action' + ZoomOut = 'zoom_out_action' + Back = 'back_action' + Forward = 'forward_action' + SelectAll = 'select_all_action' + Copy = 'copy_action' + Inspect = 'inspect_action' + Stop = 'stop_action' + Refresh = 'refresh_action' + + +class WebViewMenuSections: + Move = 'move_section' + Select = 'select_section' + Zoom = 'zoom_section' + Extras = 'extras_section' + + +class WebViewMenus: + Context = 'context_menu' + + +# --- Widgets +# ---------------------------------------------------------------------------- +class WebPage(QWebEnginePage): + """ + Web page subclass to manage hyperlinks for WebEngine + + Note: This can't be used for WebKit because the + acceptNavigationRequest method has a different + functionality for it. + """ + linkClicked = Signal(QUrl) + + def acceptNavigationRequest(self, url, navigation_type, isMainFrame): + """ + Overloaded method to handle links ourselves + """ + if navigation_type == QWebEnginePage.NavigationTypeLinkClicked: + self.linkClicked.emit(url) + return False + + return super(WebPage, self).acceptNavigationRequest( + url, navigation_type, isMainFrame) + + +class WebView(QWebEngineView, SpyderWidgetMixin): + """ + Web view. + """ + sig_focus_in_event = Signal() + """ + This signal is emitted when the widget receives focus. + """ + + sig_focus_out_event = Signal() + """ + This signal is emitted when the widget loses focus. + """ + + def __init__(self, parent, handle_links=True, class_parent=None): + class_parent = parent if class_parent is None else class_parent + if PYQT5: + super().__init__(parent, class_parent=class_parent) + else: + QWebEngineView.__init__(self, parent) + SpyderWidgetMixin.__init__(self, class_parent=class_parent) + + self.zoom_factor = 1. + self.context_menu = None + + if WEBENGINE: + if handle_links: + web_page = WebPage(self) + else: + web_page = QWebEnginePage(self) + + self.setPage(web_page) + self.source_text = '' + + def setup(self, options={}): + # Actions + original_back_action = self.pageAction(QWebEnginePage.Back) + back_action = self.create_action( + name=WebViewActions.Back, + text=_("Back"), + icon=self.create_icon('previous'), + triggered=lambda: original_back_action.trigger(), + context=Qt.WidgetWithChildrenShortcut, + ) + + original_forward_action = self.pageAction(QWebEnginePage.Forward) + forward_action = self.create_action( + name=WebViewActions.Forward, + text=_("Forward"), + icon=self.create_icon('next'), + triggered=lambda: original_forward_action.trigger(), + context=Qt.WidgetWithChildrenShortcut, + ) + + original_select_action = self.pageAction(QWebEnginePage.SelectAll) + select_all_action = self.create_action( + name=WebViewActions.SelectAll, + text=_("Select all"), + triggered=lambda: original_select_action.trigger(), + context=Qt.WidgetWithChildrenShortcut, + ) + + original_copy_action = self.pageAction(QWebEnginePage.Copy) + copy_action = self.create_action( + name=WebViewActions.Copy, + text=_("Copy"), + triggered=lambda: original_copy_action.trigger(), + context=Qt.WidgetWithChildrenShortcut, + ) + + self.zoom_in_action = self.create_action( + name=WebViewActions.ZoomIn, + text=_("Zoom in"), + icon=self.create_icon('zoom_in'), + triggered=self.zoom_in, + context=Qt.WidgetWithChildrenShortcut, + ) + + self.zoom_out_action = self.create_action( + name=WebViewActions.ZoomOut, + text=_("Zoom out"), + icon=self.create_icon('zoom_out'), + triggered=self.zoom_out, + context=Qt.WidgetWithChildrenShortcut, + ) + + original_inspect_action = self.pageAction( + QWebEnginePage.InspectElement) + inspect_action = self.create_action( + name=WebViewActions.Inspect, + text=_("Inspect"), + triggered=lambda: original_inspect_action.trigger(), + context=Qt.WidgetWithChildrenShortcut, + ) + + original_refresh_action = self.pageAction(QWebEnginePage.Reload) + self.create_action( + name=WebViewActions.Refresh, + text=_("Refresh"), + icon=self.create_icon('refresh'), + triggered=lambda: original_refresh_action.trigger(), + context=Qt.WidgetWithChildrenShortcut, + ) + + original_stop_action = self.pageAction(QWebEnginePage.Stop) + self.create_action( + name=WebViewActions.Stop, + text=_("Stop"), + icon=self.create_icon('stop'), + triggered=lambda: original_stop_action.trigger(), + context=Qt.WidgetWithChildrenShortcut, + ) + + menu = self.create_menu(WebViewMenus.Context) + self.context_menu = menu + for item in [back_action, forward_action]: + self.add_item_to_menu( + item, + menu=menu, + section=WebViewMenuSections.Move, + ) + + for item in [select_all_action, copy_action]: + self.add_item_to_menu( + item, + menu=menu, + section=WebViewMenuSections.Select, + ) + + for item in [self.zoom_in_action, self.zoom_out_action]: + self.add_item_to_menu( + item, + menu=menu, + section=WebViewMenuSections.Zoom, + ) + + self.add_item_to_menu( + inspect_action, + menu=menu, + section=WebViewMenuSections.Extras, + ) + + if DEV and not WEBENGINE: + settings = self.page().settings() + settings.setAttribute(QWebEngineSettings.DeveloperExtrasEnabled, + True) + inspect_action.setVisible(True) + else: + inspect_action.setVisible(False) + + def find_text(self, text, changed=True, forward=True, case=False, + word=False, regexp=False): + """Find text.""" + if not WEBENGINE: + findflag = QWebEnginePage.FindWrapsAroundDocument + else: + findflag = 0 + + if not forward: + findflag = findflag | QWebEnginePage.FindBackward + if case: + findflag = findflag | QWebEnginePage.FindCaseSensitively + + return self.findText(text, QWebEnginePage.FindFlags(findflag)) + + def get_selected_text(self): + """Return text selected by current text cursor""" + return self.selectedText() + + def set_source_text(self, source_text): + """Set source text of the page. Callback for QWebEngineView.""" + self.source_text = source_text + + def get_number_matches(self, pattern, source_text='', case=False, + regexp=False, word=False): + """Get the number of matches for the searched text.""" + pattern = to_text_string(pattern) + if not pattern: + return 0 + if not regexp: + pattern = re.escape(pattern) + if not source_text: + if WEBENGINE: + self.page().toPlainText(self.set_source_text) + source_text = to_text_string(self.source_text) + else: + source_text = to_text_string( + self.page().mainFrame().toPlainText()) + + if word: # match whole words only + pattern = r'\b{pattern}\b'.format(pattern=pattern) + + try: + if case: + regobj = re.compile(pattern, re.MULTILINE) + else: + regobj = re.compile(pattern, re.MULTILINE | re.IGNORECASE) + except sre_constants.error: + return + + number_matches = 0 + for match in regobj.finditer(source_text): + number_matches += 1 + + return number_matches + + def set_font(self, font, fixed_font=None): + font = QFontInfo(font) + settings = self.page().settings() + for fontfamily in (settings.StandardFont, settings.SerifFont, + settings.SansSerifFont, settings.CursiveFont, + settings.FantasyFont): + settings.setFontFamily(fontfamily, font.family()) + if fixed_font is not None: + settings.setFontFamily(settings.FixedFont, fixed_font.family()) + size = font.pixelSize() + settings.setFontSize(settings.DefaultFontSize, size) + settings.setFontSize(settings.DefaultFixedFontSize, size) + + def apply_zoom_factor(self): + """Apply zoom factor.""" + if hasattr(self, 'setZoomFactor'): + # Assuming Qt >=v4.5 + self.setZoomFactor(self.zoom_factor) + else: + # Qt v4.4 + self.setTextSizeMultiplier(self.zoom_factor) + + def set_zoom_factor(self, zoom_factor): + """Set zoom factor.""" + self.zoom_factor = zoom_factor + self.apply_zoom_factor() + + def get_zoom_factor(self): + """Return zoom factor.""" + return self.zoom_factor + + @Slot() + def zoom_out(self): + """Zoom out.""" + self.zoom_factor = max(.1, self.zoom_factor-.1) + self.apply_zoom_factor() + + @Slot() + def zoom_in(self): + """Zoom in.""" + self.zoom_factor += .1 + self.apply_zoom_factor() + + #------ QWebEngineView API ------------------------------------------------------- + def createWindow(self, webwindowtype): + import webbrowser + # See: spyder-ide/spyder#9849 + try: + webbrowser.open(to_text_string(self.url().toString())) + except ValueError: + pass + + def contextMenuEvent(self, event): + if self.context_menu: + self.context_menu.popup(event.globalPos()) + event.accept() + + def setHtml(self, html, baseUrl=QUrl()): + """ + Reimplement Qt method to prevent WebEngine to steal focus + when setting html on the page + + Solution taken from + https://bugreports.qt.io/browse/QTBUG-52999 + """ + if WEBENGINE: + if OLD_PYQT: + self.setEnabled(False) + super(WebView, self).setHtml(html, baseUrl) + if OLD_PYQT: + self.setEnabled(True) + else: + super(WebView, self).setHtml(html, baseUrl) + + # This is required to catch an error with PyQt 5.9, for which + # it seems this functionality is not working. + # Fixes spyder-ide/spyder#16703 + try: + # The event filter needs to be installed every time html is set + # because the proxy changes with new content. + self.focusProxy().installEventFilter(self) + except AttributeError: + pass + + def load(self, url): + """ + Load url. + + This is reimplemented to install our event filter after the + url is loaded. + """ + super().load(url) + self.focusProxy().installEventFilter(self) + + def eventFilter(self, widget, event): + """ + Handle events that affect the view. + + All events (e.g. focus in/out) reach the focus proxy, not this + widget itself. That's why this event filter is necessary. + """ + if self.focusProxy() is widget: + if event.type() == QEvent.FocusIn: + self.sig_focus_in_event.emit() + elif event.type() == QEvent.FocusOut: + self.sig_focus_out_event.emit() + return super().eventFilter(widget, event) + + +class WebBrowser(QWidget): + """ + Web browser widget. + """ + def __init__(self, parent=None, options_button=None, handle_links=True): + QWidget.__init__(self, parent) + + self.home_url = None + + self.webview = WebView(self, handle_links=handle_links) + self.webview.setup() + self.webview.loadFinished.connect(self.load_finished) + self.webview.titleChanged.connect(self.setWindowTitle) + self.webview.urlChanged.connect(self.url_changed) + + home_button = create_toolbutton(self, icon=ima.icon('home'), + tip=_("Home"), + triggered=self.go_home) + + zoom_out_button = action2button(self.webview.zoom_out_action) + zoom_in_button = action2button(self.webview.zoom_in_action) + + def pageact2btn(prop, icon=None): + return action2button( + self.webview.pageAction(prop), parent=self.webview, icon=icon) + + refresh_button = pageact2btn( + QWebEnginePage.Reload, icon=ima.icon('refresh')) + stop_button = pageact2btn( + QWebEnginePage.Stop, icon=ima.icon('stop')) + previous_button = pageact2btn( + QWebEnginePage.Back, icon=ima.icon('previous')) + next_button = pageact2btn( + QWebEnginePage.Forward, icon=ima.icon('next')) + + stop_button.setEnabled(False) + self.webview.loadStarted.connect(lambda: stop_button.setEnabled(True)) + self.webview.loadFinished.connect(lambda: stop_button.setEnabled(False)) + + progressbar = QProgressBar(self) + progressbar.setTextVisible(False) + progressbar.hide() + self.webview.loadStarted.connect(progressbar.show) + self.webview.loadProgress.connect(progressbar.setValue) + self.webview.loadFinished.connect(lambda _state: progressbar.hide()) + + label = QLabel(self.get_label()) + + self.url_combo = UrlComboBox(self) + self.url_combo.valid.connect(self.url_combo_activated) + if not WEBENGINE: + self.webview.iconChanged.connect(self.icon_changed) + + self.find_widget = FindReplace(self) + self.find_widget.set_editor(self.webview) + self.find_widget.hide() + + find_button = create_toolbutton(self, icon=ima.icon('find'), + tip=_("Find text"), + toggled=self.toggle_find_widget) + self.find_widget.visibility_changed.connect(find_button.setChecked) + + hlayout = QHBoxLayout() + for widget in (previous_button, next_button, home_button, find_button, + label, self.url_combo, zoom_out_button, zoom_in_button, + refresh_button, progressbar, stop_button): + hlayout.addWidget(widget) + + if options_button: + hlayout.addWidget(options_button) + + layout = create_plugin_layout(hlayout) + layout.addWidget(self.webview) + layout.addWidget(self.find_widget) + self.setLayout(layout) + + def get_label(self): + """Return address label text""" + return _("Address:") + + def set_home_url(self, text): + """Set home URL""" + self.home_url = QUrl(text) + + def set_url(self, url): + """Set current URL""" + self.url_changed(url) + self.go_to(url) + + def go_to(self, url_or_text): + """Go to page *address*""" + if is_text_string(url_or_text): + url = QUrl(url_or_text) + else: + url = url_or_text + self.webview.load(url) + + @Slot() + def go_home(self): + """Go to home page""" + if self.home_url is not None: + self.set_url(self.home_url) + + def text_to_url(self, text): + """Convert text address into QUrl object""" + return QUrl(text) + + def url_combo_activated(self, valid): + """Load URL from combo box first item""" + text = to_text_string(self.url_combo.currentText()) + self.go_to(self.text_to_url(text)) + + def load_finished(self, ok): + if not ok: + self.webview.setHtml(_("Unable to load page")) + + def url_to_text(self, url): + """Convert QUrl object to displayed text in combo box""" + return url.toString() + + def url_changed(self, url): + """Displayed URL has changed -> updating URL combo box""" + self.url_combo.add_text(self.url_to_text(url)) + + def icon_changed(self): + self.url_combo.setItemIcon(self.url_combo.currentIndex(), + self.webview.icon()) + self.setWindowIcon(self.webview.icon()) + + @Slot(bool) + def toggle_find_widget(self, state): + if state: + self.find_widget.show() + else: + self.find_widget.hide() + + +class FrameWebView(QFrame): + """ + Framed WebView for UI consistency in Spyder. + """ + linkClicked = Signal(QUrl) + + def __init__(self, parent, handle_links=True): + super().__init__(parent) + + self._webview = WebView( + self, + handle_links=handle_links, + class_parent=parent + ) + self._webview.sig_focus_in_event.connect( + lambda: self._apply_stylesheet(focus=True)) + self._webview.sig_focus_out_event.connect( + lambda: self._apply_stylesheet(focus=False)) + + layout = QHBoxLayout() + layout.addWidget(self._webview) + layout.setContentsMargins(0, 0, 0, 0) + self.setLayout(layout) + self._apply_stylesheet() + + if handle_links: + if WEBENGINE: + self._webview.page().linkClicked.connect(self.linkClicked) + else: + self._webview.linkClicked.connect(self.linkClicked) + + def __getattr__(self, name): + if name == '_webview': + return super().__getattr__(name) + + if hasattr(self._webview, name): + return getattr(self._webview, name) + else: + return super().__getattr__(name) + + @property + def web_widget(self): + return self._webview + + def _apply_stylesheet(self, focus=False): + """Apply stylesheet according to the current focus.""" + if focus: + border_color = QStylePalette.COLOR_ACCENT_3 + else: + border_color = QStylePalette.COLOR_BACKGROUND_4 + + css = qstylizer.style.StyleSheet() + css.QFrame.setValues( + border=f'1px solid {border_color}', + margin='0px 1px 0px 1px', + padding='0px 0px 1px 0px', + borderRadius='3px' + ) + + self.setStyleSheet(css.toString()) + + +def test(): + """Run web browser""" + from spyder.utils.qthelpers import qapplication + app = qapplication(test_time=8) + widget = WebBrowser() + widget.show() + widget.set_home_url('https://www.google.com/') + widget.go_home() + sys.exit(app.exec_()) + + +if __name__ == '__main__': + test() diff --git a/spyder/widgets/collectionseditor.py b/spyder/widgets/collectionseditor.py index 6dd93920fa8..79a343894ff 100644 --- a/spyder/widgets/collectionseditor.py +++ b/spyder/widgets/collectionseditor.py @@ -1,1912 +1,1912 @@ -# -*- coding: utf-8 -*- -# ----------------------------------------------------------------------------- -# Copyright © Spyder Project Contributors -# -# Licensed under the terms of the MIT License -# (see spyder/__init__.py for details) -# ---------------------------------------------------------------------------- - -""" -Collections (i.e. dictionary, list, set and tuple) editor widget and dialog. -""" - -#TODO: Multiple selection: open as many editors (array/dict/...) as necessary, -# at the same time - -# pylint: disable=C0103 -# pylint: disable=R0903 -# pylint: disable=R0911 -# pylint: disable=R0201 - -# Standard library imports -from __future__ import print_function -import datetime -import re -import sys -import warnings - -# Third party imports -from qtpy.compat import getsavefilename, to_qvariant -from qtpy.QtCore import ( - QAbstractTableModel, QItemSelectionModel, QModelIndex, Qt, Signal, Slot) -from qtpy.QtGui import QColor, QKeySequence -from qtpy.QtWidgets import ( - QApplication, QHBoxLayout, QHeaderView, QInputDialog, QLineEdit, QMenu, - QMessageBox, QPushButton, QTableView, QVBoxLayout, QWidget) -from spyder_kernels.utils.lazymodules import ( - FakeObject, numpy as np, pandas as pd, PIL) -from spyder_kernels.utils.misc import fix_reference_name -from spyder_kernels.utils.nsview import ( - display_to_value, get_human_readable_type, get_numeric_numpy_types, - get_numpy_type_string, get_object_attrs, get_size, get_type_string, - sort_against, try_to_eval, unsorted_unique, value_to_display -) - -# Local imports -from spyder.api.config.mixins import SpyderConfigurationAccessor -from spyder.api.widgets.toolbars import SpyderToolbar -from spyder.config.base import _, running_under_pytest -from spyder.config.fonts import DEFAULT_SMALL_DELTA -from spyder.config.gui import get_font -from spyder.py3compat import (io, is_binary_string, PY3, to_text_string, - is_type_text_string, NUMERIC_TYPES) -from spyder.utils.icon_manager import ima -from spyder.utils.misc import getcwd_or_home -from spyder.utils.qthelpers import ( - add_actions, create_action, MENU_SEPARATOR, mimedata2url) -from spyder.utils.stringmatching import get_search_scores, get_search_regex -from spyder.plugins.variableexplorer.widgets.collectionsdelegate import ( - CollectionsDelegate) -from spyder.plugins.variableexplorer.widgets.importwizard import ImportWizard -from spyder.widgets.helperwidgets import CustomSortFilterProxy -from spyder.plugins.variableexplorer.widgets.basedialog import BaseDialog -from spyder.utils.palette import SpyderPalette -from spyder.utils.stylesheet import PANES_TOOLBAR_STYLESHEET - - -# Maximum length of a serialized variable to be set in the kernel -MAX_SERIALIZED_LENGHT = 1e6 - -LARGE_NROWS = 100 -ROWS_TO_LOAD = 50 - - -def natsort(s): - """ - Natural sorting, e.g. test3 comes before test100. - Taken from https://stackoverflow.com/a/16090640/3110740 - """ - if not isinstance(s, (str, bytes)): - return s - x = [int(t) if t.isdigit() else t.lower() for t in re.split('([0-9]+)', s)] - return x - - -class ProxyObject(object): - """Dictionary proxy to an unknown object.""" - - def __init__(self, obj): - """Constructor.""" - self.__obj__ = obj - - def __len__(self): - """Get len according to detected attributes.""" - return len(get_object_attrs(self.__obj__)) - - def __getitem__(self, key): - """Get the attribute corresponding to the given key.""" - # Catch NotImplementedError to fix spyder-ide/spyder#6284 in pandas - # MultiIndex due to NA checking not being supported on a multiindex. - # Catch AttributeError to fix spyder-ide/spyder#5642 in certain special - # classes like xml when this method is called on certain attributes. - # Catch TypeError to prevent fatal Python crash to desktop after - # modifying certain pandas objects. Fix spyder-ide/spyder#6727. - # Catch ValueError to allow viewing and editing of pandas offsets. - # Fix spyder-ide/spyder#6728- - try: - attribute_toreturn = getattr(self.__obj__, key) - except (NotImplementedError, AttributeError, TypeError, ValueError): - attribute_toreturn = None - return attribute_toreturn - - def __setitem__(self, key, value): - """Set attribute corresponding to key with value.""" - # Catch AttributeError to gracefully handle inability to set an - # attribute due to it not being writeable or set-table. - # Fix spyder-ide/spyder#6728. - # Also, catch NotImplementedError for safety. - try: - setattr(self.__obj__, key, value) - except (TypeError, AttributeError, NotImplementedError): - pass - except Exception as e: - if "cannot set values for" not in str(e): - raise - - -class ReadOnlyCollectionsModel(QAbstractTableModel): - """CollectionsEditor Read-Only Table Model""" - - sig_setting_data = Signal() - - def __init__(self, parent, data, title="", names=False, - minmax=False, remote=False): - QAbstractTableModel.__init__(self, parent) - if data is None: - data = {} - self._parent = parent - self.scores = [] - self.names = names - self.minmax = minmax - self.remote = remote - self.header0 = None - self._data = None - self.total_rows = None - self.showndata = None - self.keys = None - self.title = to_text_string(title) # in case title is not a string - if self.title: - self.title = self.title + ' - ' - self.sizes = [] - self.types = [] - self.set_data(data) - - def get_data(self): - """Return model data""" - return self._data - - def set_data(self, data, coll_filter=None): - """Set model data""" - self._data = data - - if (coll_filter is not None and not self.remote and - isinstance(data, (tuple, list, dict, set))): - data = coll_filter(data) - self.showndata = data - - self.header0 = _("Index") - if self.names: - self.header0 = _("Name") - if isinstance(data, tuple): - self.keys = list(range(len(data))) - self.title += _("Tuple") - elif isinstance(data, list): - self.keys = list(range(len(data))) - self.title += _("List") - elif isinstance(data, set): - self.keys = list(range(len(data))) - self.title += _("Set") - self._data = list(data) - elif isinstance(data, dict): - try: - self.keys = sorted(list(data.keys()), key=natsort) - except TypeError: - # This is necessary to display dictionaries with mixed - # types as keys. - # Fixes spyder-ide/spyder#13481 - self.keys = list(data.keys()) - self.title += _("Dictionary") - if not self.names: - self.header0 = _("Key") - else: - self.keys = get_object_attrs(data) - self._data = data = self.showndata = ProxyObject(data) - if not self.names: - self.header0 = _("Attribute") - if not isinstance(self._data, ProxyObject): - if len(self.keys) > 1: - elements = _("elements") - else: - elements = _("element") - self.title += (' (' + str(len(self.keys)) + ' ' + elements + ')') - else: - data_type = get_type_string(data) - self.title += data_type - self.total_rows = len(self.keys) - if self.total_rows > LARGE_NROWS: - self.rows_loaded = ROWS_TO_LOAD - else: - self.rows_loaded = self.total_rows - self.sig_setting_data.emit() - self.set_size_and_type() - if len(self.keys): - # Needed to update search scores when - # adding values to the namespace - self.update_search_letters() - self.reset() - - def set_size_and_type(self, start=None, stop=None): - data = self._data - - if start is None and stop is None: - start = 0 - stop = self.rows_loaded - fetch_more = False - else: - fetch_more = True - - # Ignore pandas warnings that certain attributes are deprecated - # and will be removed, since they will only be accessed if they exist. - with warnings.catch_warnings(): - warnings.filterwarnings( - "ignore", message=(r"^\w+\.\w+ is deprecated and " - "will be removed in a future version")) - if self.remote: - sizes = [data[self.keys[index]]['size'] - for index in range(start, stop)] - types = [data[self.keys[index]]['type'] - for index in range(start, stop)] - else: - sizes = [get_size(data[self.keys[index]]) - for index in range(start, stop)] - types = [get_human_readable_type(data[self.keys[index]]) - for index in range(start, stop)] - - if fetch_more: - self.sizes = self.sizes + sizes - self.types = self.types + types - else: - self.sizes = sizes - self.types = types - - def load_all(self): - """Load all the data.""" - self.fetchMore(number_to_fetch=self.total_rows) - - def sort(self, column, order=Qt.AscendingOrder): - """Overriding sort method""" - - def all_string(listlike): - return all([isinstance(x, str) for x in listlike]) - - reverse = (order == Qt.DescendingOrder) - sort_key = natsort if all_string(self.keys) else None - - if column == 0: - self.sizes = sort_against(self.sizes, self.keys, - reverse=reverse, - sort_key=natsort) - self.types = sort_against(self.types, self.keys, - reverse=reverse, - sort_key=natsort) - try: - self.keys.sort(reverse=reverse, key=sort_key) - except: - pass - elif column == 1: - self.keys[:self.rows_loaded] = sort_against(self.keys, - self.types, - reverse=reverse) - self.sizes = sort_against(self.sizes, self.types, reverse=reverse) - try: - self.types.sort(reverse=reverse) - except: - pass - elif column == 2: - self.keys[:self.rows_loaded] = sort_against(self.keys, - self.sizes, - reverse=reverse) - self.types = sort_against(self.types, self.sizes, reverse=reverse) - try: - self.sizes.sort(reverse=reverse) - except: - pass - elif column in [3, 4]: - values = [self._data[key] for key in self.keys] - self.keys = sort_against(self.keys, values, reverse=reverse) - self.sizes = sort_against(self.sizes, values, reverse=reverse) - self.types = sort_against(self.types, values, reverse=reverse) - self.beginResetModel() - self.endResetModel() - - def columnCount(self, qindex=QModelIndex()): - """Array column number""" - if self._parent.proxy_model: - return 5 - else: - return 4 - - def rowCount(self, index=QModelIndex()): - """Array row number""" - if self.total_rows <= self.rows_loaded: - return self.total_rows - else: - return self.rows_loaded - - def canFetchMore(self, index=QModelIndex()): - if self.total_rows > self.rows_loaded: - return True - else: - return False - - def fetchMore(self, index=QModelIndex(), number_to_fetch=None): - reminder = self.total_rows - self.rows_loaded - if number_to_fetch is not None: - items_to_fetch = min(reminder, number_to_fetch) - else: - items_to_fetch = min(reminder, ROWS_TO_LOAD) - self.set_size_and_type(self.rows_loaded, - self.rows_loaded + items_to_fetch) - self.beginInsertRows(QModelIndex(), self.rows_loaded, - self.rows_loaded + items_to_fetch - 1) - self.rows_loaded += items_to_fetch - self.endInsertRows() - - def get_index_from_key(self, key): - try: - return self.createIndex(self.keys.index(key), 0) - except (RuntimeError, ValueError): - return QModelIndex() - - def get_key(self, index): - """Return current key""" - return self.keys[index.row()] - - def get_value(self, index): - """Return current value""" - if index.column() == 0: - return self.keys[ index.row() ] - elif index.column() == 1: - return self.types[ index.row() ] - elif index.column() == 2: - return self.sizes[ index.row() ] - else: - return self._data[ self.keys[index.row()] ] - - def get_bgcolor(self, index): - """Background color depending on value""" - if index.column() == 0: - color = QColor(Qt.lightGray) - color.setAlphaF(.05) - elif index.column() < 3: - color = QColor(Qt.lightGray) - color.setAlphaF(.2) - else: - color = QColor(Qt.lightGray) - color.setAlphaF(.3) - return color - - def update_search_letters(self, text=""): - """Update search letters with text input in search box.""" - self.letters = text - names = [str(key) for key in self.keys] - results = get_search_scores(text, names, template='{0}') - if results: - self.normal_text, _, self.scores = zip(*results) - self.reset() - - def row_key(self, row_num): - """ - Get row name based on model index. - Needed for the custom proxy model. - """ - return self.keys[row_num] - - def row_type(self, row_num): - """ - Get row type based on model index. - Needed for the custom proxy model. - """ - return self.types[row_num] - - def data(self, index, role=Qt.DisplayRole): - """Cell content""" - if not index.isValid(): - return to_qvariant() - value = self.get_value(index) - if index.column() == 4 and role == Qt.DisplayRole: - # TODO: Check the effect of not hiding the column - # Treating search scores as a table column simplifies the - # sorting once a score for a specific string in the finder - # has been defined. This column however should always remain - # hidden. - return to_qvariant(self.scores[index.row()]) - if index.column() == 3 and self.remote: - value = value['view'] - if index.column() == 3: - display = value_to_display(value, minmax=self.minmax) - else: - if is_type_text_string(value): - display = to_text_string(value, encoding="utf-8") - elif not isinstance( - value, NUMERIC_TYPES + get_numeric_numpy_types() - ): - display = to_text_string(value) - else: - display = value - if role == Qt.UserRole: - if isinstance(value, NUMERIC_TYPES + get_numeric_numpy_types()): - return to_qvariant(value) - else: - return to_qvariant(display) - elif role == Qt.DisplayRole: - return to_qvariant(display) - elif role == Qt.EditRole: - return to_qvariant(value_to_display(value)) - elif role == Qt.TextAlignmentRole: - if index.column() == 3: - if len(display.splitlines()) < 3: - return to_qvariant(int(Qt.AlignLeft|Qt.AlignVCenter)) - else: - return to_qvariant(int(Qt.AlignLeft|Qt.AlignTop)) - else: - return to_qvariant(int(Qt.AlignLeft|Qt.AlignVCenter)) - elif role == Qt.BackgroundColorRole: - return to_qvariant( self.get_bgcolor(index) ) - elif role == Qt.FontRole: - return to_qvariant(get_font(font_size_delta=DEFAULT_SMALL_DELTA)) - return to_qvariant() - - def headerData(self, section, orientation, role=Qt.DisplayRole): - """Overriding method headerData""" - if role != Qt.DisplayRole: - return to_qvariant() - i_column = int(section) - if orientation == Qt.Horizontal: - headers = (self.header0, _("Type"), _("Size"), _("Value"), - _("Score")) - return to_qvariant( headers[i_column] ) - else: - return to_qvariant() - - def flags(self, index): - """Overriding method flags""" - # This method was implemented in CollectionsModel only, but to enable - # tuple exploration (even without editing), this method was moved here - if not index.isValid(): - return Qt.ItemIsEnabled - return Qt.ItemFlags(int(QAbstractTableModel.flags(self, index) | - Qt.ItemIsEditable)) - - def reset(self): - self.beginResetModel() - self.endResetModel() - - -class CollectionsModel(ReadOnlyCollectionsModel): - """Collections Table Model""" - - def set_value(self, index, value): - """Set value""" - self._data[ self.keys[index.row()] ] = value - self.showndata[ self.keys[index.row()] ] = value - self.sizes[index.row()] = get_size(value) - self.types[index.row()] = get_human_readable_type(value) - self.sig_setting_data.emit() - - def type_to_color(self, python_type, numpy_type): - """Get the color that corresponds to a Python type.""" - # Color for unknown types - color = SpyderPalette.GROUP_12 - - if numpy_type != 'Unknown': - if numpy_type == 'Array': - color = SpyderPalette.GROUP_9 - elif numpy_type == 'Scalar': - color = SpyderPalette.GROUP_2 - elif python_type == 'bool': - color = SpyderPalette.GROUP_1 - elif python_type in ['int', 'float', 'complex']: - color = SpyderPalette.GROUP_2 - elif python_type in ['str', 'unicode']: - color = SpyderPalette.GROUP_3 - elif 'datetime' in python_type: - color = SpyderPalette.GROUP_4 - elif python_type == 'list': - color = SpyderPalette.GROUP_5 - elif python_type == 'set': - color = SpyderPalette.GROUP_6 - elif python_type == 'tuple': - color = SpyderPalette.GROUP_7 - elif python_type == 'dict': - color = SpyderPalette.GROUP_8 - elif python_type in ['MaskedArray', 'Matrix', 'NDArray']: - color = SpyderPalette.GROUP_9 - elif (python_type in ['DataFrame', 'Series'] or - 'Index' in python_type): - color = SpyderPalette.GROUP_10 - elif python_type == 'PIL.Image.Image': - color = SpyderPalette.GROUP_11 - else: - color = SpyderPalette.GROUP_12 - - return color - - def get_bgcolor(self, index): - """Background color depending on value.""" - value = self.get_value(index) - if index.column() < 3: - color = ReadOnlyCollectionsModel.get_bgcolor(self, index) - else: - if self.remote: - python_type = value['python_type'] - numpy_type = value['numpy_type'] - else: - python_type = get_type_string(value) - numpy_type = get_numpy_type_string(value) - color_name = self.type_to_color(python_type, numpy_type) - color = QColor(color_name) - color.setAlphaF(0.5) - return color - - def setData(self, index, value, role=Qt.EditRole): - """Cell content change""" - if not index.isValid(): - return False - if index.column() < 3: - return False - value = display_to_value(value, self.get_value(index), - ignore_errors=True) - self.set_value(index, value) - self.dataChanged.emit(index, index) - return True - - -class BaseHeaderView(QHeaderView): - """ - A header view for the BaseTableView that emits a signal when the width of - one of its sections is resized by the user. - """ - sig_user_resized_section = Signal(int, int, int) - - def __init__(self, parent=None): - super(BaseHeaderView, self).__init__(Qt.Horizontal, parent) - self._handle_section_is_pressed = False - self.sectionResized.connect(self.sectionResizeEvent) - # Needed to enable sorting by column - # See spyder-ide/spyder#9835 - self.setSectionsClickable(True) - - def mousePressEvent(self, e): - super(BaseHeaderView, self).mousePressEvent(e) - self._handle_section_is_pressed = (self.cursor().shape() == - Qt.SplitHCursor) - - def mouseReleaseEvent(self, e): - super(BaseHeaderView, self).mouseReleaseEvent(e) - self._handle_section_is_pressed = False - - def sectionResizeEvent(self, logicalIndex, oldSize, newSize): - if self._handle_section_is_pressed: - self.sig_user_resized_section.emit(logicalIndex, oldSize, newSize) - - -class BaseTableView(QTableView, SpyderConfigurationAccessor): - """Base collection editor table view""" - CONF_SECTION = 'variable_explorer' - - sig_files_dropped = Signal(list) - redirect_stdio = Signal(bool) - sig_free_memory_requested = Signal() - sig_editor_creation_started = Signal() - sig_editor_shown = Signal() - - def __init__(self, parent): - super().__init__(parent=parent) - - self.array_filename = None - self.menu = None - self.menu_actions = [] - self.empty_ws_menu = None - self.paste_action = None - self.copy_action = None - self.edit_action = None - self.plot_action = None - self.hist_action = None - self.imshow_action = None - self.save_array_action = None - self.insert_action = None - self.insert_action_above = None - self.insert_action_below = None - self.remove_action = None - self.minmax_action = None - self.rename_action = None - self.duplicate_action = None - self.last_regex = '' - self.view_action = None - self.delegate = None - self.proxy_model = None - self.source_model = None - self.setAcceptDrops(True) - self.automatic_column_width = True - self.setHorizontalHeader(BaseHeaderView(parent=self)) - self.horizontalHeader().sig_user_resized_section.connect( - self.user_resize_columns) - - def setup_table(self): - """Setup table""" - self.horizontalHeader().setStretchLastSection(True) - self.horizontalHeader().setSectionsMovable(True) - self.adjust_columns() - # Sorting columns - self.setSortingEnabled(True) - self.sortByColumn(0, Qt.AscendingOrder) - self.selectionModel().selectionChanged.connect(self.refresh_menu) - - def setup_menu(self): - """Setup context menu""" - resize_action = create_action(self, _("Resize rows to contents"), - icon=ima.icon('collapse_row'), - triggered=self.resizeRowsToContents) - resize_columns_action = create_action( - self, - _("Resize columns to contents"), - icon=ima.icon('collapse_column'), - triggered=self.resize_column_contents) - self.paste_action = create_action(self, _("Paste"), - icon=ima.icon('editpaste'), - triggered=self.paste) - self.copy_action = create_action(self, _("Copy"), - icon=ima.icon('editcopy'), - triggered=self.copy) - self.edit_action = create_action(self, _("Edit"), - icon=ima.icon('edit'), - triggered=self.edit_item) - self.plot_action = create_action(self, _("Plot"), - icon=ima.icon('plot'), - triggered=lambda: self.plot_item('plot')) - self.plot_action.setVisible(False) - self.hist_action = create_action(self, _("Histogram"), - icon=ima.icon('hist'), - triggered=lambda: self.plot_item('hist')) - self.hist_action.setVisible(False) - self.imshow_action = create_action(self, _("Show image"), - icon=ima.icon('imshow'), - triggered=self.imshow_item) - self.imshow_action.setVisible(False) - self.save_array_action = create_action(self, _("Save array"), - icon=ima.icon('filesave'), - triggered=self.save_array) - self.save_array_action.setVisible(False) - self.insert_action = create_action( - self, _("Insert"), - icon=ima.icon('insert'), - triggered=lambda: self.insert_item(below=False) - ) - self.insert_action_above = create_action( - self, _("Insert above"), - icon=ima.icon('insert_above'), - triggered=lambda: self.insert_item(below=False) - ) - self.insert_action_below = create_action( - self, _("Insert below"), - icon=ima.icon('insert_below'), - triggered=lambda: self.insert_item(below=True) - ) - self.remove_action = create_action(self, _("Remove"), - icon=ima.icon('editdelete'), - triggered=self.remove_item) - self.rename_action = create_action(self, _("Rename"), - icon=ima.icon('rename'), - triggered=self.rename_item) - self.duplicate_action = create_action(self, _("Duplicate"), - icon=ima.icon('edit_add'), - triggered=self.duplicate_item) - self.view_action = create_action( - self, - _("View with the Object Explorer"), - icon=ima.icon('outline_explorer'), - triggered=self.view_item) - - menu = QMenu(self) - self.menu_actions = [ - self.edit_action, - self.copy_action, - self.paste_action, - self.rename_action, - self.remove_action, - self.save_array_action, - MENU_SEPARATOR, - self.insert_action, - self.insert_action_above, - self.insert_action_below, - self.duplicate_action, - MENU_SEPARATOR, - self.view_action, - self.plot_action, - self.hist_action, - self.imshow_action, - MENU_SEPARATOR, - resize_action, - resize_columns_action - ] - add_actions(menu, self.menu_actions) - - self.empty_ws_menu = QMenu(self) - add_actions( - self.empty_ws_menu, - [self.insert_action, self.paste_action] - ) - - return menu - - - # ------ Remote/local API ------------------------------------------------- - def remove_values(self, keys): - """Remove values from data""" - raise NotImplementedError - - def copy_value(self, orig_key, new_key): - """Copy value""" - raise NotImplementedError - - def new_value(self, key, value): - """Create new value in data""" - raise NotImplementedError - - def is_list(self, key): - """Return True if variable is a list, a set or a tuple""" - raise NotImplementedError - - def get_len(self, key): - """Return sequence length""" - raise NotImplementedError - - def is_array(self, key): - """Return True if variable is a numpy array""" - raise NotImplementedError - - def is_image(self, key): - """Return True if variable is a PIL.Image image""" - raise NotImplementedError - - def is_dict(self, key): - """Return True if variable is a dictionary""" - raise NotImplementedError - - def get_array_shape(self, key): - """Return array's shape""" - raise NotImplementedError - - def get_array_ndim(self, key): - """Return array's ndim""" - raise NotImplementedError - - def oedit(self, key): - """Edit item""" - raise NotImplementedError - - def plot(self, key, funcname): - """Plot item""" - raise NotImplementedError - - def imshow(self, key): - """Show item's image""" - raise NotImplementedError - - def show_image(self, key): - """Show image (item is a PIL image)""" - raise NotImplementedError - #-------------------------------------------------------------------------- - - def refresh_menu(self): - """Refresh context menu""" - index = self.currentIndex() - data = self.source_model.get_data() - is_list_instance = isinstance(data, list) - is_dict_instance = isinstance(data, dict) - - def indexes_in_same_row(): - indexes = self.selectedIndexes() - if len(indexes) > 1: - rows = [idx.row() for idx in indexes] - return len(set(rows)) == 1 - else: - return True - - # Enable/disable actions - condition_edit = ( - (not isinstance(data, (tuple, set))) and - index.isValid() and - (len(self.selectedIndexes()) > 0) and - indexes_in_same_row() and - not self.readonly - ) - self.edit_action.setEnabled(condition_edit) - self.insert_action_above.setEnabled(condition_edit) - self.insert_action_below.setEnabled(condition_edit) - self.duplicate_action.setEnabled(condition_edit) - self.rename_action.setEnabled(condition_edit) - self.plot_action.setEnabled(condition_edit) - self.hist_action.setEnabled(condition_edit) - self.imshow_action.setEnabled(condition_edit) - self.save_array_action.setEnabled(condition_edit) - - condition_select = ( - index.isValid() and - (len(self.selectedIndexes()) > 0) - ) - self.view_action.setEnabled( - condition_select and indexes_in_same_row()) - self.copy_action.setEnabled(condition_select) - - condition_remove = ( - (not isinstance(data, (tuple, set))) and - index.isValid() and - (len(self.selectedIndexes()) > 0) and - not self.readonly - ) - self.remove_action.setEnabled(condition_remove) - - self.insert_action.setEnabled( - is_dict_instance and not self.readonly) - self.paste_action.setEnabled( - is_dict_instance and not self.readonly) - - # Hide/show actions - if index.isValid(): - if self.proxy_model: - key = self.proxy_model.get_key(index) - else: - key = self.source_model.get_key(index) - is_list = self.is_list(key) - is_array = self.is_array(key) and self.get_len(key) != 0 - condition_plot = (is_array and len(self.get_array_shape(key)) <= 2) - condition_hist = (is_array and self.get_array_ndim(key) == 1) - condition_imshow = condition_plot and self.get_array_ndim(key) == 2 - condition_imshow = condition_imshow or self.is_image(key) - else: - is_array = condition_plot = condition_imshow = is_list \ - = condition_hist = False - - self.plot_action.setVisible(condition_plot or is_list) - self.hist_action.setVisible(condition_hist or is_list) - self.insert_action.setVisible(is_dict_instance) - self.insert_action_above.setVisible(is_list_instance) - self.insert_action_below.setVisible(is_list_instance) - self.rename_action.setVisible(is_dict_instance) - self.paste_action.setVisible(is_dict_instance) - self.imshow_action.setVisible(condition_imshow) - self.save_array_action.setVisible(is_array) - - def resize_column_contents(self): - """Resize columns to contents.""" - self.automatic_column_width = True - self.adjust_columns() - - def user_resize_columns(self, logical_index, old_size, new_size): - """Handle the user resize action.""" - self.automatic_column_width = False - - def adjust_columns(self): - """Resize two first columns to contents""" - if self.automatic_column_width: - for col in range(3): - self.resizeColumnToContents(col) - - def set_data(self, data): - """Set table data""" - if data is not None: - self.source_model.set_data(data, self.dictfilter) - self.source_model.reset() - self.sortByColumn(0, Qt.AscendingOrder) - - def mousePressEvent(self, event): - """Reimplement Qt method""" - if event.button() != Qt.LeftButton: - QTableView.mousePressEvent(self, event) - return - index_clicked = self.indexAt(event.pos()) - if index_clicked.isValid(): - if index_clicked == self.currentIndex() \ - and index_clicked in self.selectedIndexes(): - self.clearSelection() - else: - QTableView.mousePressEvent(self, event) - else: - self.clearSelection() - event.accept() - - def mouseDoubleClickEvent(self, event): - """Reimplement Qt method""" - index_clicked = self.indexAt(event.pos()) - if index_clicked.isValid(): - row = index_clicked.row() - # TODO: Remove hard coded "Value" column number (3 here) - index_clicked = index_clicked.child(row, 3) - self.edit(index_clicked) - else: - event.accept() - - def keyPressEvent(self, event): - """Reimplement Qt methods""" - if event.key() == Qt.Key_Delete: - self.remove_item() - elif event.key() == Qt.Key_F2: - self.rename_item() - elif event == QKeySequence.Copy: - self.copy() - elif event == QKeySequence.Paste: - self.paste() - else: - QTableView.keyPressEvent(self, event) - - def contextMenuEvent(self, event): - """Reimplement Qt method""" - if self.source_model.showndata: - self.refresh_menu() - self.menu.popup(event.globalPos()) - event.accept() - else: - self.empty_ws_menu.popup(event.globalPos()) - event.accept() - - def dragEnterEvent(self, event): - """Allow user to drag files""" - if mimedata2url(event.mimeData()): - event.accept() - else: - event.ignore() - - def dragMoveEvent(self, event): - """Allow user to move files""" - if mimedata2url(event.mimeData()): - event.setDropAction(Qt.CopyAction) - event.accept() - else: - event.ignore() - - def dropEvent(self, event): - """Allow user to drop supported files""" - urls = mimedata2url(event.mimeData()) - if urls: - event.setDropAction(Qt.CopyAction) - event.accept() - self.sig_files_dropped.emit(urls) - else: - event.ignore() - - def _deselect_index(self, index): - """ - Deselect index after any operation that adds or removes rows to/from - the editor. - - Notes - ----- - * This avoids showing the wrong buttons in the editor's toolbar when - the operation is completed. - * Also, if we leave something selected, then the next operation won't - introduce the item in the expected row. That's why we need to force - users to select a row again after this. - """ - self.selectionModel().select(index, QItemSelectionModel.Select) - self.selectionModel().select(index, QItemSelectionModel.Deselect) - - @Slot() - def edit_item(self): - """Edit item""" - index = self.currentIndex() - if not index.isValid(): - return - # TODO: Remove hard coded "Value" column number (3 here) - self.edit(index.child(index.row(), 3)) - - @Slot() - def remove_item(self, force=False): - """Remove item""" - current_index = self.currentIndex() - indexes = self.selectedIndexes() - - if not indexes: - return - - for index in indexes: - if not index.isValid(): - return - - if not force: - one = _("Do you want to remove the selected item?") - more = _("Do you want to remove all selected items?") - answer = QMessageBox.question(self, _("Remove"), - one if len(indexes) == 1 else more, - QMessageBox.Yes | QMessageBox.No) - - if force or answer == QMessageBox.Yes: - if self.proxy_model: - idx_rows = unsorted_unique( - [self.proxy_model.mapToSource(idx).row() - for idx in indexes]) - else: - idx_rows = unsorted_unique([idx.row() for idx in indexes]) - keys = [self.source_model.keys[idx_row] for idx_row in idx_rows] - self.remove_values(keys) - - # This avoids a segfault in our tests that doesn't happen when - # removing items manually. - if not running_under_pytest(): - self._deselect_index(current_index) - - def copy_item(self, erase_original=False, new_name=None): - """Copy item""" - current_index = self.currentIndex() - indexes = self.selectedIndexes() - - if not indexes: - return - - if self.proxy_model: - idx_rows = unsorted_unique( - [self.proxy_model.mapToSource(idx).row() for idx in indexes]) - else: - idx_rows = unsorted_unique([idx.row() for idx in indexes]) - - if len(idx_rows) > 1 or not indexes[0].isValid(): - return - - orig_key = self.source_model.keys[idx_rows[0]] - if erase_original: - if not isinstance(orig_key, str): - QMessageBox.warning( - self, - _("Warning"), - _("You can only rename keys that are strings") - ) - return - - title = _('Rename') - field_text = _('New variable name:') - else: - title = _('Duplicate') - field_text = _('Variable name:') - - data = self.source_model.get_data() - if isinstance(data, (list, set)): - new_key, valid = len(data), True - elif new_name is not None: - new_key, valid = new_name, True - else: - new_key, valid = QInputDialog.getText(self, title, field_text, - QLineEdit.Normal, orig_key) - - if valid and to_text_string(new_key): - new_key = try_to_eval(to_text_string(new_key)) - if new_key == orig_key: - return - self.copy_value(orig_key, new_key) - if erase_original: - self.remove_values([orig_key]) - - self._deselect_index(current_index) - - @Slot() - def duplicate_item(self): - """Duplicate item""" - self.copy_item() - - @Slot() - def rename_item(self, new_name=None): - """Rename item""" - self.copy_item(erase_original=True, new_name=new_name) - - @Slot() - def insert_item(self, below=True): - """Insert item""" - index = self.currentIndex() - if not index.isValid(): - row = self.source_model.rowCount() - else: - if self.proxy_model: - if below: - row = self.proxy_model.mapToSource(index).row() + 1 - else: - row = self.proxy_model.mapToSource(index).row() - else: - if below: - row = index.row() + 1 - else: - row = index.row() - data = self.source_model.get_data() - - if isinstance(data, list): - key = row - data.insert(row, '') - elif isinstance(data, dict): - key, valid = QInputDialog.getText(self, _('Insert'), _('Key:'), - QLineEdit.Normal) - if valid and to_text_string(key): - key = try_to_eval(to_text_string(key)) - else: - return - else: - return - - value, valid = QInputDialog.getText(self, _('Insert'), _('Value:'), - QLineEdit.Normal) - - if valid and to_text_string(value): - self.new_value(key, try_to_eval(to_text_string(value))) - - @Slot() - def view_item(self): - """View item with the Object Explorer""" - index = self.currentIndex() - if not index.isValid(): - return - # TODO: Remove hard coded "Value" column number (3 here) - index = index.child(index.row(), 3) - self.delegate.createEditor(self, None, index, object_explorer=True) - - def __prepare_plot(self): - try: - import guiqwt.pyplot #analysis:ignore - return True - except: - try: - if 'matplotlib' not in sys.modules: - import matplotlib - return True - except Exception: - QMessageBox.warning(self, _("Import error"), - _("Please install matplotlib" - " or guiqwt.")) - - def plot_item(self, funcname): - """Plot item""" - index = self.currentIndex() - if self.__prepare_plot(): - if self.proxy_model: - key = self.source_model.get_key( - self.proxy_model.mapToSource(index)) - else: - key = self.source_model.get_key(index) - try: - self.plot(key, funcname) - except (ValueError, TypeError) as error: - QMessageBox.critical(self, _( "Plot"), - _("Unable to plot data." - "

    Error message:
    %s" - ) % str(error)) - - @Slot() - def imshow_item(self): - """Imshow item""" - index = self.currentIndex() - if self.__prepare_plot(): - if self.proxy_model: - key = self.source_model.get_key( - self.proxy_model.mapToSource(index)) - else: - key = self.source_model.get_key(index) - try: - if self.is_image(key): - self.show_image(key) - else: - self.imshow(key) - except (ValueError, TypeError) as error: - QMessageBox.critical(self, _( "Plot"), - _("Unable to show image." - "

    Error message:
    %s" - ) % str(error)) - - @Slot() - def save_array(self): - """Save array""" - title = _( "Save array") - if self.array_filename is None: - self.array_filename = getcwd_or_home() - self.redirect_stdio.emit(False) - filename, _selfilter = getsavefilename(self, title, - self.array_filename, - _("NumPy arrays")+" (*.npy)") - self.redirect_stdio.emit(True) - if filename: - self.array_filename = filename - data = self.delegate.get_value( self.currentIndex() ) - try: - import numpy as np - np.save(self.array_filename, data) - except Exception as error: - QMessageBox.critical(self, title, - _("Unable to save array" - "

    Error message:
    %s" - ) % str(error)) - - @Slot() - def copy(self): - """Copy text to clipboard""" - clipboard = QApplication.clipboard() - clipl = [] - for idx in self.selectedIndexes(): - if not idx.isValid(): - continue - obj = self.delegate.get_value(idx) - # Check if we are trying to copy a numpy array, and if so make sure - # to copy the whole thing in a tab separated format - if (isinstance(obj, (np.ndarray, np.ma.MaskedArray)) and - np.ndarray is not FakeObject): - if PY3: - output = io.BytesIO() - else: - output = io.StringIO() - try: - np.savetxt(output, obj, delimiter='\t') - except Exception: - QMessageBox.warning(self, _("Warning"), - _("It was not possible to copy " - "this array")) - return - obj = output.getvalue().decode('utf-8') - output.close() - elif (isinstance(obj, (pd.DataFrame, pd.Series)) and - pd.DataFrame is not FakeObject): - output = io.StringIO() - try: - obj.to_csv(output, sep='\t', index=True, header=True) - except Exception: - QMessageBox.warning(self, _("Warning"), - _("It was not possible to copy " - "this dataframe")) - return - if PY3: - obj = output.getvalue() - else: - obj = output.getvalue().decode('utf-8') - output.close() - elif is_binary_string(obj): - obj = to_text_string(obj, 'utf8') - else: - obj = to_text_string(obj) - clipl.append(obj) - clipboard.setText('\n'.join(clipl)) - - def import_from_string(self, text, title=None): - """Import data from string""" - data = self.source_model.get_data() - # Check if data is a dict - if not hasattr(data, "keys"): - return - editor = ImportWizard( - self, text, title=title, contents_title=_("Clipboard contents"), - varname=fix_reference_name("data", blacklist=list(data.keys()))) - if editor.exec_(): - var_name, clip_data = editor.get_data() - self.new_value(var_name, clip_data) - - @Slot() - def paste(self): - """Import text/data/code from clipboard""" - clipboard = QApplication.clipboard() - cliptext = '' - if clipboard.mimeData().hasText(): - cliptext = to_text_string(clipboard.text()) - if cliptext.strip(): - self.import_from_string(cliptext, title=_("Import from clipboard")) - else: - QMessageBox.warning(self, _( "Empty clipboard"), - _("Nothing to be imported from clipboard.")) - - -class CollectionsEditorTableView(BaseTableView): - """CollectionsEditor table view""" - def __init__(self, parent, data, readonly=False, title="", - names=False): - BaseTableView.__init__(self, parent) - self.dictfilter = None - self.readonly = readonly or isinstance(data, (tuple, set)) - CollectionsModelClass = (ReadOnlyCollectionsModel if self.readonly - else CollectionsModel) - self.source_model = CollectionsModelClass( - self, - data, - title, - names=names, - minmax=self.get_conf('minmax') - ) - self.model = self.source_model - self.setModel(self.source_model) - self.delegate = CollectionsDelegate(self) - self.setItemDelegate(self.delegate) - - self.setup_table() - self.menu = self.setup_menu() - if isinstance(data, set): - self.horizontalHeader().hideSection(0) - - #------ Remote/local API -------------------------------------------------- - def remove_values(self, keys): - """Remove values from data""" - data = self.source_model.get_data() - for key in sorted(keys, reverse=True): - data.pop(key) - self.set_data(data) - - def copy_value(self, orig_key, new_key): - """Copy value""" - data = self.source_model.get_data() - if isinstance(data, list): - data.append(data[orig_key]) - if isinstance(data, set): - data.add(data[orig_key]) - else: - data[new_key] = data[orig_key] - self.set_data(data) - - def new_value(self, key, value): - """Create new value in data""" - index = self.currentIndex() - data = self.source_model.get_data() - data[key] = value - self.set_data(data) - self._deselect_index(index) - - def is_list(self, key): - """Return True if variable is a list or a tuple""" - data = self.source_model.get_data() - return isinstance(data[key], (tuple, list)) - - def is_set(self, key): - """Return True if variable is a set""" - data = self.source_model.get_data() - return isinstance(data[key], set) - - def get_len(self, key): - """Return sequence length""" - data = self.source_model.get_data() - return len(data[key]) - - def is_array(self, key): - """Return True if variable is a numpy array""" - data = self.source_model.get_data() - return isinstance(data[key], (np.ndarray, np.ma.MaskedArray)) - - def is_image(self, key): - """Return True if variable is a PIL.Image image""" - data = self.source_model.get_data() - return isinstance(data[key], PIL.Image.Image) - - def is_dict(self, key): - """Return True if variable is a dictionary""" - data = self.source_model.get_data() - return isinstance(data[key], dict) - - def get_array_shape(self, key): - """Return array's shape""" - data = self.source_model.get_data() - return data[key].shape - - def get_array_ndim(self, key): - """Return array's ndim""" - data = self.source_model.get_data() - return data[key].ndim - - def oedit(self, key): - """Edit item""" - data = self.source_model.get_data() - from spyder.plugins.variableexplorer.widgets.objecteditor import ( - oedit) - oedit(data[key]) - - def plot(self, key, funcname): - """Plot item""" - data = self.source_model.get_data() - import spyder.pyplot as plt - plt.figure() - getattr(plt, funcname)(data[key]) - plt.show() - - def imshow(self, key): - """Show item's image""" - data = self.source_model.get_data() - import spyder.pyplot as plt - plt.figure() - plt.imshow(data[key]) - plt.show() - - def show_image(self, key): - """Show image (item is a PIL image)""" - data = self.source_model.get_data() - data[key].show() - #-------------------------------------------------------------------------- - - def set_filter(self, dictfilter=None): - """Set table dict filter""" - self.dictfilter = dictfilter - - -class CollectionsEditorWidget(QWidget): - """Dictionary Editor Widget""" - def __init__(self, parent, data, readonly=False, title="", remote=False): - QWidget.__init__(self, parent) - if remote: - self.editor = RemoteCollectionsEditorTableView(self, data, readonly) - else: - self.editor = CollectionsEditorTableView(self, data, readonly, - title) - - toolbar = SpyderToolbar(parent=None, title='Editor toolbar') - toolbar.setStyleSheet(str(PANES_TOOLBAR_STYLESHEET)) - - for item in self.editor.menu_actions: - if item is not None: - toolbar.addAction(item) - - # Update the toolbar actions state - self.editor.refresh_menu() - layout = QVBoxLayout() - layout.addWidget(toolbar) - layout.addWidget(self.editor) - self.setLayout(layout) - - def set_data(self, data): - """Set DictEditor data""" - self.editor.set_data(data) - - def get_title(self): - """Get model title""" - return self.editor.source_model.title - - -class CollectionsEditor(BaseDialog): - """Collections Editor Dialog""" - def __init__(self, parent=None): - super().__init__(parent) - - # 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.data_copy = None - self.widget = None - self.btn_save_and_close = None - self.btn_close = None - - def setup(self, data, title='', readonly=False, remote=False, - icon=None, parent=None): - """Setup editor.""" - if isinstance(data, (dict, set)): - # dictionary, set - self.data_copy = data.copy() - datalen = len(data) - elif isinstance(data, (tuple, list)): - # list, tuple - self.data_copy = data[:] - datalen = len(data) - else: - # unknown object - import copy - try: - self.data_copy = copy.deepcopy(data) - except NotImplementedError: - self.data_copy = copy.copy(data) - except (TypeError, AttributeError): - readonly = True - self.data_copy = data - datalen = len(get_object_attrs(data)) - - # If the copy has a different type, then do not allow editing, because - # this would change the type after saving; cf. spyder-ide/spyder#6936. - if type(self.data_copy) != type(data): - readonly = True - - self.widget = CollectionsEditorWidget(self, self.data_copy, - title=title, readonly=readonly, - remote=remote) - self.widget.editor.source_model.sig_setting_data.connect( - self.save_and_close_enable) - layout = QVBoxLayout() - layout.addWidget(self.widget) - self.setLayout(layout) - - # Buttons configuration - btn_layout = QHBoxLayout() - btn_layout.setContentsMargins(4, 4, 4, 4) - btn_layout.addStretch() - - if not readonly: - self.btn_save_and_close = QPushButton(_('Save and Close')) - self.btn_save_and_close.setDisabled(True) - self.btn_save_and_close.clicked.connect(self.accept) - btn_layout.addWidget(self.btn_save_and_close) - - self.btn_close = QPushButton(_('Close')) - self.btn_close.setAutoDefault(True) - self.btn_close.setDefault(True) - self.btn_close.clicked.connect(self.reject) - btn_layout.addWidget(self.btn_close) - - layout.addLayout(btn_layout) - - self.setWindowTitle(self.widget.get_title()) - if icon is None: - self.setWindowIcon(ima.icon('dictedit')) - - if sys.platform == 'darwin': - # See spyder-ide/spyder#9051 - self.setWindowFlags(Qt.Tool) - else: - # Make the dialog act as a window - self.setWindowFlags(Qt.Window) - - @Slot() - def save_and_close_enable(self): - """Handle the data change event to enable the save and close button.""" - if self.btn_save_and_close: - self.btn_save_and_close.setEnabled(True) - self.btn_save_and_close.setAutoDefault(True) - self.btn_save_and_close.setDefault(True) - - def get_value(self): - """Return modified copy of dictionary or list""" - # It is import to avoid accessing Qt C++ object as it has probably - # already been destroyed, due to the Qt.WA_DeleteOnClose attribute - return self.data_copy - - -#============================================================================== -# Remote versions of CollectionsDelegate and CollectionsEditorTableView -#============================================================================== -class RemoteCollectionsDelegate(CollectionsDelegate): - """CollectionsEditor Item Delegate""" - def __init__(self, parent=None): - CollectionsDelegate.__init__(self, parent) - - def get_value(self, index): - if index.isValid(): - source_index = index.model().mapToSource(index) - name = source_index.model().keys[source_index.row()] - return self.parent().get_value(name) - - def set_value(self, index, value): - if index.isValid(): - source_index = index.model().mapToSource(index) - name = source_index.model().keys[source_index.row()] - self.parent().new_value(name, value) - - -class RemoteCollectionsEditorTableView(BaseTableView): - """DictEditor table view""" - def __init__(self, parent, data, shellwidget=None, remote_editing=False, - create_menu=False): - BaseTableView.__init__(self, parent) - - self.shellwidget = shellwidget - self.var_properties = {} - self.dictfilter = None - self.delegate = None - self.readonly = False - self.finder = None - - self.source_model = CollectionsModel( - self, data, names=True, - minmax=self.get_conf('minmax'), - remote=True) - - self.horizontalHeader().sectionClicked.connect( - self.source_model.load_all) - - self.proxy_model = CollectionsCustomSortFilterProxy(self) - self.model = self.proxy_model - - self.proxy_model.setSourceModel(self.source_model) - self.proxy_model.setDynamicSortFilter(True) - self.proxy_model.setFilterKeyColumn(0) # Col 0 for Name - self.proxy_model.setFilterCaseSensitivity(Qt.CaseInsensitive) - self.proxy_model.setSortRole(Qt.UserRole) - self.setModel(self.proxy_model) - - self.hideColumn(4) # Column 4 for Score - - self.delegate = RemoteCollectionsDelegate(self) - self.delegate.sig_free_memory_requested.connect( - self.sig_free_memory_requested) - self.delegate.sig_editor_creation_started.connect( - self.sig_editor_creation_started) - self.delegate.sig_editor_shown.connect(self.sig_editor_shown) - self.setItemDelegate(self.delegate) - - self.setup_table() - - if create_menu: - self.menu = self.setup_menu() - - # ------ Remote/local API ------------------------------------------------- - def get_value(self, name): - """Get the value of a variable""" - value = self.shellwidget.get_value(name) - return value - - def new_value(self, name, value): - """Create new value in data""" - try: - self.shellwidget.set_value(name, value) - except TypeError as e: - QMessageBox.critical(self, _("Error"), - "TypeError: %s" % to_text_string(e)) - self.shellwidget.refresh_namespacebrowser() - - def remove_values(self, names): - """Remove values from data""" - for name in names: - self.shellwidget.remove_value(name) - self.shellwidget.refresh_namespacebrowser() - - def copy_value(self, orig_name, new_name): - """Copy value""" - self.shellwidget.copy_value(orig_name, new_name) - self.shellwidget.refresh_namespacebrowser() - - def is_list(self, name): - """Return True if variable is a list, a tuple or a set""" - return self.var_properties[name]['is_list'] - - def is_dict(self, name): - """Return True if variable is a dictionary""" - return self.var_properties[name]['is_dict'] - - def get_len(self, name): - """Return sequence length""" - return self.var_properties[name]['len'] - - def is_array(self, name): - """Return True if variable is a NumPy array""" - return self.var_properties[name]['is_array'] - - def is_image(self, name): - """Return True if variable is a PIL.Image image""" - return self.var_properties[name]['is_image'] - - def is_data_frame(self, name): - """Return True if variable is a DataFrame""" - return self.var_properties[name]['is_data_frame'] - - def is_series(self, name): - """Return True if variable is a Series""" - return self.var_properties[name]['is_series'] - - def get_array_shape(self, name): - """Return array's shape""" - return self.var_properties[name]['array_shape'] - - def get_array_ndim(self, name): - """Return array's ndim""" - return self.var_properties[name]['array_ndim'] - - def plot(self, name, funcname): - """Plot item""" - sw = self.shellwidget - sw.execute("%%varexp --%s %s" % (funcname, name)) - - def imshow(self, name): - """Show item's image""" - sw = self.shellwidget - sw.execute("%%varexp --imshow %s" % name) - - def show_image(self, name): - """Show image (item is a PIL image)""" - command = "%s.show()" % name - sw = self.shellwidget - sw.execute(command) - - # ------ Other ------------------------------------------------------------ - def setup_menu(self): - """Setup context menu.""" - menu = BaseTableView.setup_menu(self) - return menu - - def refresh_menu(self): - if self.var_properties: - super().refresh_menu() - - def set_regex(self, regex=None, reset=False): - """Update the regex text for the variable finder.""" - if reset or self.finder is None or not self.finder.text(): - text = '' - else: - text = self.finder.text().replace(' ', '').lower() - - self.proxy_model.set_filter(text) - self.source_model.update_search_letters(text) - - if text: - # TODO: Use constants for column numbers - self.sortByColumn(4, Qt.DescendingOrder) # Col 4 for index - - self.last_regex = regex - - def next_row(self): - """Move to next row from currently selected row.""" - row = self.currentIndex().row() - rows = self.proxy_model.rowCount() - if row + 1 == rows: - row = -1 - self.selectRow(row + 1) - - def previous_row(self): - """Move to previous row from currently selected row.""" - row = self.currentIndex().row() - rows = self.proxy_model.rowCount() - if row == 0: - row = rows - self.selectRow(row - 1) - - -class CollectionsCustomSortFilterProxy(CustomSortFilterProxy): - """ - Custom column filter based on regex and model data. - - Reimplements 'filterAcceptsRow' to follow NamespaceBrowser model. - Reimplements 'set_filter' to allow sorting while filtering - """ - - def get_key(self, index): - """Return current key from source model.""" - source_index = self.mapToSource(index) - return self.sourceModel().get_key(source_index) - - def get_index_from_key(self, key): - """Return index using key from source model.""" - source_index = self.sourceModel().get_index_from_key(key) - return self.mapFromSource(source_index) - - def get_value(self, index): - """Return current value from source model.""" - source_index = self.mapToSource(index) - return self.sourceModel().get_value(source_index) - - def set_value(self, index, value): - """Set value in source model.""" - try: - source_index = self.mapToSource(index) - self.sourceModel().set_value(source_index, value) - except AttributeError: - # Read-only models don't have set_value method - pass - - def set_filter(self, text): - """Set regular expression for filter.""" - self.pattern = get_search_regex(text) - self.invalidateFilter() - - def filterAcceptsRow(self, row_num, parent): - """ - Qt override. - - Reimplemented from base class to allow the use of custom filtering - using to columns (name and type). - """ - model = self.sourceModel() - name = to_text_string(model.row_key(row_num)) - variable_type = to_text_string(model.row_type(row_num)) - r_name = re.search(self.pattern, name) - r_type = re.search(self.pattern, variable_type) - - if r_name is None and r_type is None: - return False - else: - return True - - def lessThan(self, left, right): - """ - Implements ordering in a natural way, as a human would sort. - This functions enables sorting of the main variable editor table, - which does not rely on 'self.sort()'. - """ - leftData = self.sourceModel().data(left) - rightData = self.sourceModel().data(right) - try: - if isinstance(leftData, str) and isinstance(rightData, str): - return natsort(leftData) < natsort(rightData) - else: - return leftData < rightData - except TypeError: - # This is needed so all the elements that cannot be compared such - # as dataframes and numpy arrays are grouped together in the - # variable explorer. For more info see spyder-ide/spyder#14527 - return True - - -# ============================================================================= -# Tests -# ============================================================================= -def get_test_data(): - """Create test data.""" - image = PIL.Image.fromarray(np.random.randint(256, size=(100, 100)), - mode='P') - testdict = {'d': 1, 'a': np.random.rand(10, 10), 'b': [1, 2]} - testdate = datetime.date(1945, 5, 8) - test_timedelta = datetime.timedelta(days=-1, minutes=42, seconds=13) - - try: - import pandas as pd - except (ModuleNotFoundError, ImportError): - test_df = None - test_timestamp = test_pd_td = test_dtindex = test_series = None - else: - test_timestamp = pd.Timestamp("1945-05-08T23:01:00.12345") - test_pd_td = pd.Timedelta(days=2193, hours=12) - test_dtindex = pd.date_range(start="1939-09-01T", - end="1939-10-06", - freq="12H") - test_series = pd.Series({"series_name": [0, 1, 2, 3, 4, 5]}) - test_df = pd.DataFrame({"string_col": ["a", "b", "c", "d"], - "int_col": [0, 1, 2, 3], - "float_col": [1.1, 2.2, 3.3, 4.4], - "bool_col": [True, False, False, True]}) - - class Foobar(object): - - def __init__(self): - self.text = "toto" - self.testdict = testdict - self.testdate = testdate - - foobar = Foobar() - return {'object': foobar, - 'module': np, - 'str': 'kjkj kj k j j kj k jkj', - 'unicode': to_text_string('éù', 'utf-8'), - 'list': [1, 3, [sorted, 5, 6], 'kjkj', None], - 'set': {1, 2, 1, 3, None, 'A', 'B', 'C', True, False}, - 'tuple': ([1, testdate, testdict, test_timedelta], 'kjkj', None), - 'dict': testdict, - 'float': 1.2233, - 'int': 223, - 'bool': True, - 'array': np.random.rand(10, 10).astype(np.int64), - 'masked_array': np.ma.array([[1, 0], [1, 0]], - mask=[[True, False], [False, False]]), - '1D-array': np.linspace(-10, 10).astype(np.float16), - '3D-array': np.random.randint(2, size=(5, 5, 5)).astype(np.bool_), - 'empty_array': np.array([]), - 'image': image, - 'date': testdate, - 'datetime': datetime.datetime(1945, 5, 8, 23, 1, 0, int(1.5e5)), - 'timedelta': test_timedelta, - 'complex': 2+1j, - 'complex64': np.complex64(2+1j), - 'complex128': np.complex128(9j), - 'int8_scalar': np.int8(8), - 'int16_scalar': np.int16(16), - 'int32_scalar': np.int32(32), - 'int64_scalar': np.int64(64), - 'float16_scalar': np.float16(16), - 'float32_scalar': np.float32(32), - 'float64_scalar': np.float64(64), - 'bool_scalar': np.bool(8), - 'bool__scalar': np.bool_(8), - 'timestamp': test_timestamp, - 'timedelta_pd': test_pd_td, - 'datetimeindex': test_dtindex, - 'series': test_series, - 'ddataframe': test_df, - 'None': None, - 'unsupported1': np.arccos, - 'unsupported2': np.cast, - # Test for spyder-ide/spyder#3518. - 'big_struct_array': np.zeros(1000, dtype=[('ID', 'f8'), - ('param1', 'f8', 5000)]), - } - - -def editor_test(): - """Test Collections editor.""" - dialog = CollectionsEditor() - dialog.setup(get_test_data()) - dialog.show() - - -def remote_editor_test(): - """Test remote collections editor.""" - from spyder.config.manager import CONF - from spyder_kernels.utils.nsview import (make_remote_view, - REMOTE_SETTINGS) - - settings = {} - for name in REMOTE_SETTINGS: - settings[name] = CONF.get('variable_explorer', name) - - remote = make_remote_view(get_test_data(), settings) - dialog = CollectionsEditor() - dialog.setup(remote, remote=True) - dialog.show() - - -if __name__ == "__main__": - from spyder.utils.qthelpers import qapplication - - app = qapplication() # analysis:ignore - editor_test() - remote_editor_test() - app.exec_() +# -*- coding: utf-8 -*- +# ----------------------------------------------------------------------------- +# Copyright © Spyder Project Contributors +# +# Licensed under the terms of the MIT License +# (see spyder/__init__.py for details) +# ---------------------------------------------------------------------------- + +""" +Collections (i.e. dictionary, list, set and tuple) editor widget and dialog. +""" + +#TODO: Multiple selection: open as many editors (array/dict/...) as necessary, +# at the same time + +# pylint: disable=C0103 +# pylint: disable=R0903 +# pylint: disable=R0911 +# pylint: disable=R0201 + +# Standard library imports +from __future__ import print_function +import datetime +import re +import sys +import warnings + +# Third party imports +from qtpy.compat import getsavefilename, to_qvariant +from qtpy.QtCore import ( + QAbstractTableModel, QItemSelectionModel, QModelIndex, Qt, Signal, Slot) +from qtpy.QtGui import QColor, QKeySequence +from qtpy.QtWidgets import ( + QApplication, QHBoxLayout, QHeaderView, QInputDialog, QLineEdit, QMenu, + QMessageBox, QPushButton, QTableView, QVBoxLayout, QWidget) +from spyder_kernels.utils.lazymodules import ( + FakeObject, numpy as np, pandas as pd, PIL) +from spyder_kernels.utils.misc import fix_reference_name +from spyder_kernels.utils.nsview import ( + display_to_value, get_human_readable_type, get_numeric_numpy_types, + get_numpy_type_string, get_object_attrs, get_size, get_type_string, + sort_against, try_to_eval, unsorted_unique, value_to_display +) + +# Local imports +from spyder.api.config.mixins import SpyderConfigurationAccessor +from spyder.api.widgets.toolbars import SpyderToolbar +from spyder.config.base import _, running_under_pytest +from spyder.config.fonts import DEFAULT_SMALL_DELTA +from spyder.config.gui import get_font +from spyder.py3compat import (io, is_binary_string, PY3, to_text_string, + is_type_text_string, NUMERIC_TYPES) +from spyder.utils.icon_manager import ima +from spyder.utils.misc import getcwd_or_home +from spyder.utils.qthelpers import ( + add_actions, create_action, MENU_SEPARATOR, mimedata2url) +from spyder.utils.stringmatching import get_search_scores, get_search_regex +from spyder.plugins.variableexplorer.widgets.collectionsdelegate import ( + CollectionsDelegate) +from spyder.plugins.variableexplorer.widgets.importwizard import ImportWizard +from spyder.widgets.helperwidgets import CustomSortFilterProxy +from spyder.plugins.variableexplorer.widgets.basedialog import BaseDialog +from spyder.utils.palette import SpyderPalette +from spyder.utils.stylesheet import PANES_TOOLBAR_STYLESHEET + + +# Maximum length of a serialized variable to be set in the kernel +MAX_SERIALIZED_LENGHT = 1e6 + +LARGE_NROWS = 100 +ROWS_TO_LOAD = 50 + + +def natsort(s): + """ + Natural sorting, e.g. test3 comes before test100. + Taken from https://stackoverflow.com/a/16090640/3110740 + """ + if not isinstance(s, (str, bytes)): + return s + x = [int(t) if t.isdigit() else t.lower() for t in re.split('([0-9]+)', s)] + return x + + +class ProxyObject(object): + """Dictionary proxy to an unknown object.""" + + def __init__(self, obj): + """Constructor.""" + self.__obj__ = obj + + def __len__(self): + """Get len according to detected attributes.""" + return len(get_object_attrs(self.__obj__)) + + def __getitem__(self, key): + """Get the attribute corresponding to the given key.""" + # Catch NotImplementedError to fix spyder-ide/spyder#6284 in pandas + # MultiIndex due to NA checking not being supported on a multiindex. + # Catch AttributeError to fix spyder-ide/spyder#5642 in certain special + # classes like xml when this method is called on certain attributes. + # Catch TypeError to prevent fatal Python crash to desktop after + # modifying certain pandas objects. Fix spyder-ide/spyder#6727. + # Catch ValueError to allow viewing and editing of pandas offsets. + # Fix spyder-ide/spyder#6728- + try: + attribute_toreturn = getattr(self.__obj__, key) + except (NotImplementedError, AttributeError, TypeError, ValueError): + attribute_toreturn = None + return attribute_toreturn + + def __setitem__(self, key, value): + """Set attribute corresponding to key with value.""" + # Catch AttributeError to gracefully handle inability to set an + # attribute due to it not being writeable or set-table. + # Fix spyder-ide/spyder#6728. + # Also, catch NotImplementedError for safety. + try: + setattr(self.__obj__, key, value) + except (TypeError, AttributeError, NotImplementedError): + pass + except Exception as e: + if "cannot set values for" not in str(e): + raise + + +class ReadOnlyCollectionsModel(QAbstractTableModel): + """CollectionsEditor Read-Only Table Model""" + + sig_setting_data = Signal() + + def __init__(self, parent, data, title="", names=False, + minmax=False, remote=False): + QAbstractTableModel.__init__(self, parent) + if data is None: + data = {} + self._parent = parent + self.scores = [] + self.names = names + self.minmax = minmax + self.remote = remote + self.header0 = None + self._data = None + self.total_rows = None + self.showndata = None + self.keys = None + self.title = to_text_string(title) # in case title is not a string + if self.title: + self.title = self.title + ' - ' + self.sizes = [] + self.types = [] + self.set_data(data) + + def get_data(self): + """Return model data""" + return self._data + + def set_data(self, data, coll_filter=None): + """Set model data""" + self._data = data + + if (coll_filter is not None and not self.remote and + isinstance(data, (tuple, list, dict, set))): + data = coll_filter(data) + self.showndata = data + + self.header0 = _("Index") + if self.names: + self.header0 = _("Name") + if isinstance(data, tuple): + self.keys = list(range(len(data))) + self.title += _("Tuple") + elif isinstance(data, list): + self.keys = list(range(len(data))) + self.title += _("List") + elif isinstance(data, set): + self.keys = list(range(len(data))) + self.title += _("Set") + self._data = list(data) + elif isinstance(data, dict): + try: + self.keys = sorted(list(data.keys()), key=natsort) + except TypeError: + # This is necessary to display dictionaries with mixed + # types as keys. + # Fixes spyder-ide/spyder#13481 + self.keys = list(data.keys()) + self.title += _("Dictionary") + if not self.names: + self.header0 = _("Key") + else: + self.keys = get_object_attrs(data) + self._data = data = self.showndata = ProxyObject(data) + if not self.names: + self.header0 = _("Attribute") + if not isinstance(self._data, ProxyObject): + if len(self.keys) > 1: + elements = _("elements") + else: + elements = _("element") + self.title += (' (' + str(len(self.keys)) + ' ' + elements + ')') + else: + data_type = get_type_string(data) + self.title += data_type + self.total_rows = len(self.keys) + if self.total_rows > LARGE_NROWS: + self.rows_loaded = ROWS_TO_LOAD + else: + self.rows_loaded = self.total_rows + self.sig_setting_data.emit() + self.set_size_and_type() + if len(self.keys): + # Needed to update search scores when + # adding values to the namespace + self.update_search_letters() + self.reset() + + def set_size_and_type(self, start=None, stop=None): + data = self._data + + if start is None and stop is None: + start = 0 + stop = self.rows_loaded + fetch_more = False + else: + fetch_more = True + + # Ignore pandas warnings that certain attributes are deprecated + # and will be removed, since they will only be accessed if they exist. + with warnings.catch_warnings(): + warnings.filterwarnings( + "ignore", message=(r"^\w+\.\w+ is deprecated and " + "will be removed in a future version")) + if self.remote: + sizes = [data[self.keys[index]]['size'] + for index in range(start, stop)] + types = [data[self.keys[index]]['type'] + for index in range(start, stop)] + else: + sizes = [get_size(data[self.keys[index]]) + for index in range(start, stop)] + types = [get_human_readable_type(data[self.keys[index]]) + for index in range(start, stop)] + + if fetch_more: + self.sizes = self.sizes + sizes + self.types = self.types + types + else: + self.sizes = sizes + self.types = types + + def load_all(self): + """Load all the data.""" + self.fetchMore(number_to_fetch=self.total_rows) + + def sort(self, column, order=Qt.AscendingOrder): + """Overriding sort method""" + + def all_string(listlike): + return all([isinstance(x, str) for x in listlike]) + + reverse = (order == Qt.DescendingOrder) + sort_key = natsort if all_string(self.keys) else None + + if column == 0: + self.sizes = sort_against(self.sizes, self.keys, + reverse=reverse, + sort_key=natsort) + self.types = sort_against(self.types, self.keys, + reverse=reverse, + sort_key=natsort) + try: + self.keys.sort(reverse=reverse, key=sort_key) + except: + pass + elif column == 1: + self.keys[:self.rows_loaded] = sort_against(self.keys, + self.types, + reverse=reverse) + self.sizes = sort_against(self.sizes, self.types, reverse=reverse) + try: + self.types.sort(reverse=reverse) + except: + pass + elif column == 2: + self.keys[:self.rows_loaded] = sort_against(self.keys, + self.sizes, + reverse=reverse) + self.types = sort_against(self.types, self.sizes, reverse=reverse) + try: + self.sizes.sort(reverse=reverse) + except: + pass + elif column in [3, 4]: + values = [self._data[key] for key in self.keys] + self.keys = sort_against(self.keys, values, reverse=reverse) + self.sizes = sort_against(self.sizes, values, reverse=reverse) + self.types = sort_against(self.types, values, reverse=reverse) + self.beginResetModel() + self.endResetModel() + + def columnCount(self, qindex=QModelIndex()): + """Array column number""" + if self._parent.proxy_model: + return 5 + else: + return 4 + + def rowCount(self, index=QModelIndex()): + """Array row number""" + if self.total_rows <= self.rows_loaded: + return self.total_rows + else: + return self.rows_loaded + + def canFetchMore(self, index=QModelIndex()): + if self.total_rows > self.rows_loaded: + return True + else: + return False + + def fetchMore(self, index=QModelIndex(), number_to_fetch=None): + reminder = self.total_rows - self.rows_loaded + if number_to_fetch is not None: + items_to_fetch = min(reminder, number_to_fetch) + else: + items_to_fetch = min(reminder, ROWS_TO_LOAD) + self.set_size_and_type(self.rows_loaded, + self.rows_loaded + items_to_fetch) + self.beginInsertRows(QModelIndex(), self.rows_loaded, + self.rows_loaded + items_to_fetch - 1) + self.rows_loaded += items_to_fetch + self.endInsertRows() + + def get_index_from_key(self, key): + try: + return self.createIndex(self.keys.index(key), 0) + except (RuntimeError, ValueError): + return QModelIndex() + + def get_key(self, index): + """Return current key""" + return self.keys[index.row()] + + def get_value(self, index): + """Return current value""" + if index.column() == 0: + return self.keys[ index.row() ] + elif index.column() == 1: + return self.types[ index.row() ] + elif index.column() == 2: + return self.sizes[ index.row() ] + else: + return self._data[ self.keys[index.row()] ] + + def get_bgcolor(self, index): + """Background color depending on value""" + if index.column() == 0: + color = QColor(Qt.lightGray) + color.setAlphaF(.05) + elif index.column() < 3: + color = QColor(Qt.lightGray) + color.setAlphaF(.2) + else: + color = QColor(Qt.lightGray) + color.setAlphaF(.3) + return color + + def update_search_letters(self, text=""): + """Update search letters with text input in search box.""" + self.letters = text + names = [str(key) for key in self.keys] + results = get_search_scores(text, names, template='{0}') + if results: + self.normal_text, _, self.scores = zip(*results) + self.reset() + + def row_key(self, row_num): + """ + Get row name based on model index. + Needed for the custom proxy model. + """ + return self.keys[row_num] + + def row_type(self, row_num): + """ + Get row type based on model index. + Needed for the custom proxy model. + """ + return self.types[row_num] + + def data(self, index, role=Qt.DisplayRole): + """Cell content""" + if not index.isValid(): + return to_qvariant() + value = self.get_value(index) + if index.column() == 4 and role == Qt.DisplayRole: + # TODO: Check the effect of not hiding the column + # Treating search scores as a table column simplifies the + # sorting once a score for a specific string in the finder + # has been defined. This column however should always remain + # hidden. + return to_qvariant(self.scores[index.row()]) + if index.column() == 3 and self.remote: + value = value['view'] + if index.column() == 3: + display = value_to_display(value, minmax=self.minmax) + else: + if is_type_text_string(value): + display = to_text_string(value, encoding="utf-8") + elif not isinstance( + value, NUMERIC_TYPES + get_numeric_numpy_types() + ): + display = to_text_string(value) + else: + display = value + if role == Qt.UserRole: + if isinstance(value, NUMERIC_TYPES + get_numeric_numpy_types()): + return to_qvariant(value) + else: + return to_qvariant(display) + elif role == Qt.DisplayRole: + return to_qvariant(display) + elif role == Qt.EditRole: + return to_qvariant(value_to_display(value)) + elif role == Qt.TextAlignmentRole: + if index.column() == 3: + if len(display.splitlines()) < 3: + return to_qvariant(int(Qt.AlignLeft|Qt.AlignVCenter)) + else: + return to_qvariant(int(Qt.AlignLeft|Qt.AlignTop)) + else: + return to_qvariant(int(Qt.AlignLeft|Qt.AlignVCenter)) + elif role == Qt.BackgroundColorRole: + return to_qvariant( self.get_bgcolor(index) ) + elif role == Qt.FontRole: + return to_qvariant(get_font(font_size_delta=DEFAULT_SMALL_DELTA)) + return to_qvariant() + + def headerData(self, section, orientation, role=Qt.DisplayRole): + """Overriding method headerData""" + if role != Qt.DisplayRole: + return to_qvariant() + i_column = int(section) + if orientation == Qt.Horizontal: + headers = (self.header0, _("Type"), _("Size"), _("Value"), + _("Score")) + return to_qvariant( headers[i_column] ) + else: + return to_qvariant() + + def flags(self, index): + """Overriding method flags""" + # This method was implemented in CollectionsModel only, but to enable + # tuple exploration (even without editing), this method was moved here + if not index.isValid(): + return Qt.ItemIsEnabled + return Qt.ItemFlags(int(QAbstractTableModel.flags(self, index) | + Qt.ItemIsEditable)) + + def reset(self): + self.beginResetModel() + self.endResetModel() + + +class CollectionsModel(ReadOnlyCollectionsModel): + """Collections Table Model""" + + def set_value(self, index, value): + """Set value""" + self._data[ self.keys[index.row()] ] = value + self.showndata[ self.keys[index.row()] ] = value + self.sizes[index.row()] = get_size(value) + self.types[index.row()] = get_human_readable_type(value) + self.sig_setting_data.emit() + + def type_to_color(self, python_type, numpy_type): + """Get the color that corresponds to a Python type.""" + # Color for unknown types + color = SpyderPalette.GROUP_12 + + if numpy_type != 'Unknown': + if numpy_type == 'Array': + color = SpyderPalette.GROUP_9 + elif numpy_type == 'Scalar': + color = SpyderPalette.GROUP_2 + elif python_type == 'bool': + color = SpyderPalette.GROUP_1 + elif python_type in ['int', 'float', 'complex']: + color = SpyderPalette.GROUP_2 + elif python_type in ['str', 'unicode']: + color = SpyderPalette.GROUP_3 + elif 'datetime' in python_type: + color = SpyderPalette.GROUP_4 + elif python_type == 'list': + color = SpyderPalette.GROUP_5 + elif python_type == 'set': + color = SpyderPalette.GROUP_6 + elif python_type == 'tuple': + color = SpyderPalette.GROUP_7 + elif python_type == 'dict': + color = SpyderPalette.GROUP_8 + elif python_type in ['MaskedArray', 'Matrix', 'NDArray']: + color = SpyderPalette.GROUP_9 + elif (python_type in ['DataFrame', 'Series'] or + 'Index' in python_type): + color = SpyderPalette.GROUP_10 + elif python_type == 'PIL.Image.Image': + color = SpyderPalette.GROUP_11 + else: + color = SpyderPalette.GROUP_12 + + return color + + def get_bgcolor(self, index): + """Background color depending on value.""" + value = self.get_value(index) + if index.column() < 3: + color = ReadOnlyCollectionsModel.get_bgcolor(self, index) + else: + if self.remote: + python_type = value['python_type'] + numpy_type = value['numpy_type'] + else: + python_type = get_type_string(value) + numpy_type = get_numpy_type_string(value) + color_name = self.type_to_color(python_type, numpy_type) + color = QColor(color_name) + color.setAlphaF(0.5) + return color + + def setData(self, index, value, role=Qt.EditRole): + """Cell content change""" + if not index.isValid(): + return False + if index.column() < 3: + return False + value = display_to_value(value, self.get_value(index), + ignore_errors=True) + self.set_value(index, value) + self.dataChanged.emit(index, index) + return True + + +class BaseHeaderView(QHeaderView): + """ + A header view for the BaseTableView that emits a signal when the width of + one of its sections is resized by the user. + """ + sig_user_resized_section = Signal(int, int, int) + + def __init__(self, parent=None): + super(BaseHeaderView, self).__init__(Qt.Horizontal, parent) + self._handle_section_is_pressed = False + self.sectionResized.connect(self.sectionResizeEvent) + # Needed to enable sorting by column + # See spyder-ide/spyder#9835 + self.setSectionsClickable(True) + + def mousePressEvent(self, e): + super(BaseHeaderView, self).mousePressEvent(e) + self._handle_section_is_pressed = (self.cursor().shape() == + Qt.SplitHCursor) + + def mouseReleaseEvent(self, e): + super(BaseHeaderView, self).mouseReleaseEvent(e) + self._handle_section_is_pressed = False + + def sectionResizeEvent(self, logicalIndex, oldSize, newSize): + if self._handle_section_is_pressed: + self.sig_user_resized_section.emit(logicalIndex, oldSize, newSize) + + +class BaseTableView(QTableView, SpyderConfigurationAccessor): + """Base collection editor table view""" + CONF_SECTION = 'variable_explorer' + + sig_files_dropped = Signal(list) + redirect_stdio = Signal(bool) + sig_free_memory_requested = Signal() + sig_editor_creation_started = Signal() + sig_editor_shown = Signal() + + def __init__(self, parent): + super().__init__(parent=parent) + + self.array_filename = None + self.menu = None + self.menu_actions = [] + self.empty_ws_menu = None + self.paste_action = None + self.copy_action = None + self.edit_action = None + self.plot_action = None + self.hist_action = None + self.imshow_action = None + self.save_array_action = None + self.insert_action = None + self.insert_action_above = None + self.insert_action_below = None + self.remove_action = None + self.minmax_action = None + self.rename_action = None + self.duplicate_action = None + self.last_regex = '' + self.view_action = None + self.delegate = None + self.proxy_model = None + self.source_model = None + self.setAcceptDrops(True) + self.automatic_column_width = True + self.setHorizontalHeader(BaseHeaderView(parent=self)) + self.horizontalHeader().sig_user_resized_section.connect( + self.user_resize_columns) + + def setup_table(self): + """Setup table""" + self.horizontalHeader().setStretchLastSection(True) + self.horizontalHeader().setSectionsMovable(True) + self.adjust_columns() + # Sorting columns + self.setSortingEnabled(True) + self.sortByColumn(0, Qt.AscendingOrder) + self.selectionModel().selectionChanged.connect(self.refresh_menu) + + def setup_menu(self): + """Setup context menu""" + resize_action = create_action(self, _("Resize rows to contents"), + icon=ima.icon('collapse_row'), + triggered=self.resizeRowsToContents) + resize_columns_action = create_action( + self, + _("Resize columns to contents"), + icon=ima.icon('collapse_column'), + triggered=self.resize_column_contents) + self.paste_action = create_action(self, _("Paste"), + icon=ima.icon('editpaste'), + triggered=self.paste) + self.copy_action = create_action(self, _("Copy"), + icon=ima.icon('editcopy'), + triggered=self.copy) + self.edit_action = create_action(self, _("Edit"), + icon=ima.icon('edit'), + triggered=self.edit_item) + self.plot_action = create_action(self, _("Plot"), + icon=ima.icon('plot'), + triggered=lambda: self.plot_item('plot')) + self.plot_action.setVisible(False) + self.hist_action = create_action(self, _("Histogram"), + icon=ima.icon('hist'), + triggered=lambda: self.plot_item('hist')) + self.hist_action.setVisible(False) + self.imshow_action = create_action(self, _("Show image"), + icon=ima.icon('imshow'), + triggered=self.imshow_item) + self.imshow_action.setVisible(False) + self.save_array_action = create_action(self, _("Save array"), + icon=ima.icon('filesave'), + triggered=self.save_array) + self.save_array_action.setVisible(False) + self.insert_action = create_action( + self, _("Insert"), + icon=ima.icon('insert'), + triggered=lambda: self.insert_item(below=False) + ) + self.insert_action_above = create_action( + self, _("Insert above"), + icon=ima.icon('insert_above'), + triggered=lambda: self.insert_item(below=False) + ) + self.insert_action_below = create_action( + self, _("Insert below"), + icon=ima.icon('insert_below'), + triggered=lambda: self.insert_item(below=True) + ) + self.remove_action = create_action(self, _("Remove"), + icon=ima.icon('editdelete'), + triggered=self.remove_item) + self.rename_action = create_action(self, _("Rename"), + icon=ima.icon('rename'), + triggered=self.rename_item) + self.duplicate_action = create_action(self, _("Duplicate"), + icon=ima.icon('edit_add'), + triggered=self.duplicate_item) + self.view_action = create_action( + self, + _("View with the Object Explorer"), + icon=ima.icon('outline_explorer'), + triggered=self.view_item) + + menu = QMenu(self) + self.menu_actions = [ + self.edit_action, + self.copy_action, + self.paste_action, + self.rename_action, + self.remove_action, + self.save_array_action, + MENU_SEPARATOR, + self.insert_action, + self.insert_action_above, + self.insert_action_below, + self.duplicate_action, + MENU_SEPARATOR, + self.view_action, + self.plot_action, + self.hist_action, + self.imshow_action, + MENU_SEPARATOR, + resize_action, + resize_columns_action + ] + add_actions(menu, self.menu_actions) + + self.empty_ws_menu = QMenu(self) + add_actions( + self.empty_ws_menu, + [self.insert_action, self.paste_action] + ) + + return menu + + + # ------ Remote/local API ------------------------------------------------- + def remove_values(self, keys): + """Remove values from data""" + raise NotImplementedError + + def copy_value(self, orig_key, new_key): + """Copy value""" + raise NotImplementedError + + def new_value(self, key, value): + """Create new value in data""" + raise NotImplementedError + + def is_list(self, key): + """Return True if variable is a list, a set or a tuple""" + raise NotImplementedError + + def get_len(self, key): + """Return sequence length""" + raise NotImplementedError + + def is_array(self, key): + """Return True if variable is a numpy array""" + raise NotImplementedError + + def is_image(self, key): + """Return True if variable is a PIL.Image image""" + raise NotImplementedError + + def is_dict(self, key): + """Return True if variable is a dictionary""" + raise NotImplementedError + + def get_array_shape(self, key): + """Return array's shape""" + raise NotImplementedError + + def get_array_ndim(self, key): + """Return array's ndim""" + raise NotImplementedError + + def oedit(self, key): + """Edit item""" + raise NotImplementedError + + def plot(self, key, funcname): + """Plot item""" + raise NotImplementedError + + def imshow(self, key): + """Show item's image""" + raise NotImplementedError + + def show_image(self, key): + """Show image (item is a PIL image)""" + raise NotImplementedError + #-------------------------------------------------------------------------- + + def refresh_menu(self): + """Refresh context menu""" + index = self.currentIndex() + data = self.source_model.get_data() + is_list_instance = isinstance(data, list) + is_dict_instance = isinstance(data, dict) + + def indexes_in_same_row(): + indexes = self.selectedIndexes() + if len(indexes) > 1: + rows = [idx.row() for idx in indexes] + return len(set(rows)) == 1 + else: + return True + + # Enable/disable actions + condition_edit = ( + (not isinstance(data, (tuple, set))) and + index.isValid() and + (len(self.selectedIndexes()) > 0) and + indexes_in_same_row() and + not self.readonly + ) + self.edit_action.setEnabled(condition_edit) + self.insert_action_above.setEnabled(condition_edit) + self.insert_action_below.setEnabled(condition_edit) + self.duplicate_action.setEnabled(condition_edit) + self.rename_action.setEnabled(condition_edit) + self.plot_action.setEnabled(condition_edit) + self.hist_action.setEnabled(condition_edit) + self.imshow_action.setEnabled(condition_edit) + self.save_array_action.setEnabled(condition_edit) + + condition_select = ( + index.isValid() and + (len(self.selectedIndexes()) > 0) + ) + self.view_action.setEnabled( + condition_select and indexes_in_same_row()) + self.copy_action.setEnabled(condition_select) + + condition_remove = ( + (not isinstance(data, (tuple, set))) and + index.isValid() and + (len(self.selectedIndexes()) > 0) and + not self.readonly + ) + self.remove_action.setEnabled(condition_remove) + + self.insert_action.setEnabled( + is_dict_instance and not self.readonly) + self.paste_action.setEnabled( + is_dict_instance and not self.readonly) + + # Hide/show actions + if index.isValid(): + if self.proxy_model: + key = self.proxy_model.get_key(index) + else: + key = self.source_model.get_key(index) + is_list = self.is_list(key) + is_array = self.is_array(key) and self.get_len(key) != 0 + condition_plot = (is_array and len(self.get_array_shape(key)) <= 2) + condition_hist = (is_array and self.get_array_ndim(key) == 1) + condition_imshow = condition_plot and self.get_array_ndim(key) == 2 + condition_imshow = condition_imshow or self.is_image(key) + else: + is_array = condition_plot = condition_imshow = is_list \ + = condition_hist = False + + self.plot_action.setVisible(condition_plot or is_list) + self.hist_action.setVisible(condition_hist or is_list) + self.insert_action.setVisible(is_dict_instance) + self.insert_action_above.setVisible(is_list_instance) + self.insert_action_below.setVisible(is_list_instance) + self.rename_action.setVisible(is_dict_instance) + self.paste_action.setVisible(is_dict_instance) + self.imshow_action.setVisible(condition_imshow) + self.save_array_action.setVisible(is_array) + + def resize_column_contents(self): + """Resize columns to contents.""" + self.automatic_column_width = True + self.adjust_columns() + + def user_resize_columns(self, logical_index, old_size, new_size): + """Handle the user resize action.""" + self.automatic_column_width = False + + def adjust_columns(self): + """Resize two first columns to contents""" + if self.automatic_column_width: + for col in range(3): + self.resizeColumnToContents(col) + + def set_data(self, data): + """Set table data""" + if data is not None: + self.source_model.set_data(data, self.dictfilter) + self.source_model.reset() + self.sortByColumn(0, Qt.AscendingOrder) + + def mousePressEvent(self, event): + """Reimplement Qt method""" + if event.button() != Qt.LeftButton: + QTableView.mousePressEvent(self, event) + return + index_clicked = self.indexAt(event.pos()) + if index_clicked.isValid(): + if index_clicked == self.currentIndex() \ + and index_clicked in self.selectedIndexes(): + self.clearSelection() + else: + QTableView.mousePressEvent(self, event) + else: + self.clearSelection() + event.accept() + + def mouseDoubleClickEvent(self, event): + """Reimplement Qt method""" + index_clicked = self.indexAt(event.pos()) + if index_clicked.isValid(): + row = index_clicked.row() + # TODO: Remove hard coded "Value" column number (3 here) + index_clicked = index_clicked.child(row, 3) + self.edit(index_clicked) + else: + event.accept() + + def keyPressEvent(self, event): + """Reimplement Qt methods""" + if event.key() == Qt.Key_Delete: + self.remove_item() + elif event.key() == Qt.Key_F2: + self.rename_item() + elif event == QKeySequence.Copy: + self.copy() + elif event == QKeySequence.Paste: + self.paste() + else: + QTableView.keyPressEvent(self, event) + + def contextMenuEvent(self, event): + """Reimplement Qt method""" + if self.source_model.showndata: + self.refresh_menu() + self.menu.popup(event.globalPos()) + event.accept() + else: + self.empty_ws_menu.popup(event.globalPos()) + event.accept() + + def dragEnterEvent(self, event): + """Allow user to drag files""" + if mimedata2url(event.mimeData()): + event.accept() + else: + event.ignore() + + def dragMoveEvent(self, event): + """Allow user to move files""" + if mimedata2url(event.mimeData()): + event.setDropAction(Qt.CopyAction) + event.accept() + else: + event.ignore() + + def dropEvent(self, event): + """Allow user to drop supported files""" + urls = mimedata2url(event.mimeData()) + if urls: + event.setDropAction(Qt.CopyAction) + event.accept() + self.sig_files_dropped.emit(urls) + else: + event.ignore() + + def _deselect_index(self, index): + """ + Deselect index after any operation that adds or removes rows to/from + the editor. + + Notes + ----- + * This avoids showing the wrong buttons in the editor's toolbar when + the operation is completed. + * Also, if we leave something selected, then the next operation won't + introduce the item in the expected row. That's why we need to force + users to select a row again after this. + """ + self.selectionModel().select(index, QItemSelectionModel.Select) + self.selectionModel().select(index, QItemSelectionModel.Deselect) + + @Slot() + def edit_item(self): + """Edit item""" + index = self.currentIndex() + if not index.isValid(): + return + # TODO: Remove hard coded "Value" column number (3 here) + self.edit(index.child(index.row(), 3)) + + @Slot() + def remove_item(self, force=False): + """Remove item""" + current_index = self.currentIndex() + indexes = self.selectedIndexes() + + if not indexes: + return + + for index in indexes: + if not index.isValid(): + return + + if not force: + one = _("Do you want to remove the selected item?") + more = _("Do you want to remove all selected items?") + answer = QMessageBox.question(self, _("Remove"), + one if len(indexes) == 1 else more, + QMessageBox.Yes | QMessageBox.No) + + if force or answer == QMessageBox.Yes: + if self.proxy_model: + idx_rows = unsorted_unique( + [self.proxy_model.mapToSource(idx).row() + for idx in indexes]) + else: + idx_rows = unsorted_unique([idx.row() for idx in indexes]) + keys = [self.source_model.keys[idx_row] for idx_row in idx_rows] + self.remove_values(keys) + + # This avoids a segfault in our tests that doesn't happen when + # removing items manually. + if not running_under_pytest(): + self._deselect_index(current_index) + + def copy_item(self, erase_original=False, new_name=None): + """Copy item""" + current_index = self.currentIndex() + indexes = self.selectedIndexes() + + if not indexes: + return + + if self.proxy_model: + idx_rows = unsorted_unique( + [self.proxy_model.mapToSource(idx).row() for idx in indexes]) + else: + idx_rows = unsorted_unique([idx.row() for idx in indexes]) + + if len(idx_rows) > 1 or not indexes[0].isValid(): + return + + orig_key = self.source_model.keys[idx_rows[0]] + if erase_original: + if not isinstance(orig_key, str): + QMessageBox.warning( + self, + _("Warning"), + _("You can only rename keys that are strings") + ) + return + + title = _('Rename') + field_text = _('New variable name:') + else: + title = _('Duplicate') + field_text = _('Variable name:') + + data = self.source_model.get_data() + if isinstance(data, (list, set)): + new_key, valid = len(data), True + elif new_name is not None: + new_key, valid = new_name, True + else: + new_key, valid = QInputDialog.getText(self, title, field_text, + QLineEdit.Normal, orig_key) + + if valid and to_text_string(new_key): + new_key = try_to_eval(to_text_string(new_key)) + if new_key == orig_key: + return + self.copy_value(orig_key, new_key) + if erase_original: + self.remove_values([orig_key]) + + self._deselect_index(current_index) + + @Slot() + def duplicate_item(self): + """Duplicate item""" + self.copy_item() + + @Slot() + def rename_item(self, new_name=None): + """Rename item""" + self.copy_item(erase_original=True, new_name=new_name) + + @Slot() + def insert_item(self, below=True): + """Insert item""" + index = self.currentIndex() + if not index.isValid(): + row = self.source_model.rowCount() + else: + if self.proxy_model: + if below: + row = self.proxy_model.mapToSource(index).row() + 1 + else: + row = self.proxy_model.mapToSource(index).row() + else: + if below: + row = index.row() + 1 + else: + row = index.row() + data = self.source_model.get_data() + + if isinstance(data, list): + key = row + data.insert(row, '') + elif isinstance(data, dict): + key, valid = QInputDialog.getText(self, _('Insert'), _('Key:'), + QLineEdit.Normal) + if valid and to_text_string(key): + key = try_to_eval(to_text_string(key)) + else: + return + else: + return + + value, valid = QInputDialog.getText(self, _('Insert'), _('Value:'), + QLineEdit.Normal) + + if valid and to_text_string(value): + self.new_value(key, try_to_eval(to_text_string(value))) + + @Slot() + def view_item(self): + """View item with the Object Explorer""" + index = self.currentIndex() + if not index.isValid(): + return + # TODO: Remove hard coded "Value" column number (3 here) + index = index.child(index.row(), 3) + self.delegate.createEditor(self, None, index, object_explorer=True) + + def __prepare_plot(self): + try: + import guiqwt.pyplot #analysis:ignore + return True + except: + try: + if 'matplotlib' not in sys.modules: + import matplotlib + return True + except Exception: + QMessageBox.warning(self, _("Import error"), + _("Please install matplotlib" + " or guiqwt.")) + + def plot_item(self, funcname): + """Plot item""" + index = self.currentIndex() + if self.__prepare_plot(): + if self.proxy_model: + key = self.source_model.get_key( + self.proxy_model.mapToSource(index)) + else: + key = self.source_model.get_key(index) + try: + self.plot(key, funcname) + except (ValueError, TypeError) as error: + QMessageBox.critical(self, _( "Plot"), + _("Unable to plot data." + "

    Error message:
    %s" + ) % str(error)) + + @Slot() + def imshow_item(self): + """Imshow item""" + index = self.currentIndex() + if self.__prepare_plot(): + if self.proxy_model: + key = self.source_model.get_key( + self.proxy_model.mapToSource(index)) + else: + key = self.source_model.get_key(index) + try: + if self.is_image(key): + self.show_image(key) + else: + self.imshow(key) + except (ValueError, TypeError) as error: + QMessageBox.critical(self, _( "Plot"), + _("Unable to show image." + "

    Error message:
    %s" + ) % str(error)) + + @Slot() + def save_array(self): + """Save array""" + title = _( "Save array") + if self.array_filename is None: + self.array_filename = getcwd_or_home() + self.redirect_stdio.emit(False) + filename, _selfilter = getsavefilename(self, title, + self.array_filename, + _("NumPy arrays")+" (*.npy)") + self.redirect_stdio.emit(True) + if filename: + self.array_filename = filename + data = self.delegate.get_value( self.currentIndex() ) + try: + import numpy as np + np.save(self.array_filename, data) + except Exception as error: + QMessageBox.critical(self, title, + _("Unable to save array" + "

    Error message:
    %s" + ) % str(error)) + + @Slot() + def copy(self): + """Copy text to clipboard""" + clipboard = QApplication.clipboard() + clipl = [] + for idx in self.selectedIndexes(): + if not idx.isValid(): + continue + obj = self.delegate.get_value(idx) + # Check if we are trying to copy a numpy array, and if so make sure + # to copy the whole thing in a tab separated format + if (isinstance(obj, (np.ndarray, np.ma.MaskedArray)) and + np.ndarray is not FakeObject): + if PY3: + output = io.BytesIO() + else: + output = io.StringIO() + try: + np.savetxt(output, obj, delimiter='\t') + except Exception: + QMessageBox.warning(self, _("Warning"), + _("It was not possible to copy " + "this array")) + return + obj = output.getvalue().decode('utf-8') + output.close() + elif (isinstance(obj, (pd.DataFrame, pd.Series)) and + pd.DataFrame is not FakeObject): + output = io.StringIO() + try: + obj.to_csv(output, sep='\t', index=True, header=True) + except Exception: + QMessageBox.warning(self, _("Warning"), + _("It was not possible to copy " + "this dataframe")) + return + if PY3: + obj = output.getvalue() + else: + obj = output.getvalue().decode('utf-8') + output.close() + elif is_binary_string(obj): + obj = to_text_string(obj, 'utf8') + else: + obj = to_text_string(obj) + clipl.append(obj) + clipboard.setText('\n'.join(clipl)) + + def import_from_string(self, text, title=None): + """Import data from string""" + data = self.source_model.get_data() + # Check if data is a dict + if not hasattr(data, "keys"): + return + editor = ImportWizard( + self, text, title=title, contents_title=_("Clipboard contents"), + varname=fix_reference_name("data", blacklist=list(data.keys()))) + if editor.exec_(): + var_name, clip_data = editor.get_data() + self.new_value(var_name, clip_data) + + @Slot() + def paste(self): + """Import text/data/code from clipboard""" + clipboard = QApplication.clipboard() + cliptext = '' + if clipboard.mimeData().hasText(): + cliptext = to_text_string(clipboard.text()) + if cliptext.strip(): + self.import_from_string(cliptext, title=_("Import from clipboard")) + else: + QMessageBox.warning(self, _( "Empty clipboard"), + _("Nothing to be imported from clipboard.")) + + +class CollectionsEditorTableView(BaseTableView): + """CollectionsEditor table view""" + def __init__(self, parent, data, readonly=False, title="", + names=False): + BaseTableView.__init__(self, parent) + self.dictfilter = None + self.readonly = readonly or isinstance(data, (tuple, set)) + CollectionsModelClass = (ReadOnlyCollectionsModel if self.readonly + else CollectionsModel) + self.source_model = CollectionsModelClass( + self, + data, + title, + names=names, + minmax=self.get_conf('minmax') + ) + self.model = self.source_model + self.setModel(self.source_model) + self.delegate = CollectionsDelegate(self) + self.setItemDelegate(self.delegate) + + self.setup_table() + self.menu = self.setup_menu() + if isinstance(data, set): + self.horizontalHeader().hideSection(0) + + #------ Remote/local API -------------------------------------------------- + def remove_values(self, keys): + """Remove values from data""" + data = self.source_model.get_data() + for key in sorted(keys, reverse=True): + data.pop(key) + self.set_data(data) + + def copy_value(self, orig_key, new_key): + """Copy value""" + data = self.source_model.get_data() + if isinstance(data, list): + data.append(data[orig_key]) + if isinstance(data, set): + data.add(data[orig_key]) + else: + data[new_key] = data[orig_key] + self.set_data(data) + + def new_value(self, key, value): + """Create new value in data""" + index = self.currentIndex() + data = self.source_model.get_data() + data[key] = value + self.set_data(data) + self._deselect_index(index) + + def is_list(self, key): + """Return True if variable is a list or a tuple""" + data = self.source_model.get_data() + return isinstance(data[key], (tuple, list)) + + def is_set(self, key): + """Return True if variable is a set""" + data = self.source_model.get_data() + return isinstance(data[key], set) + + def get_len(self, key): + """Return sequence length""" + data = self.source_model.get_data() + return len(data[key]) + + def is_array(self, key): + """Return True if variable is a numpy array""" + data = self.source_model.get_data() + return isinstance(data[key], (np.ndarray, np.ma.MaskedArray)) + + def is_image(self, key): + """Return True if variable is a PIL.Image image""" + data = self.source_model.get_data() + return isinstance(data[key], PIL.Image.Image) + + def is_dict(self, key): + """Return True if variable is a dictionary""" + data = self.source_model.get_data() + return isinstance(data[key], dict) + + def get_array_shape(self, key): + """Return array's shape""" + data = self.source_model.get_data() + return data[key].shape + + def get_array_ndim(self, key): + """Return array's ndim""" + data = self.source_model.get_data() + return data[key].ndim + + def oedit(self, key): + """Edit item""" + data = self.source_model.get_data() + from spyder.plugins.variableexplorer.widgets.objecteditor import ( + oedit) + oedit(data[key]) + + def plot(self, key, funcname): + """Plot item""" + data = self.source_model.get_data() + import spyder.pyplot as plt + plt.figure() + getattr(plt, funcname)(data[key]) + plt.show() + + def imshow(self, key): + """Show item's image""" + data = self.source_model.get_data() + import spyder.pyplot as plt + plt.figure() + plt.imshow(data[key]) + plt.show() + + def show_image(self, key): + """Show image (item is a PIL image)""" + data = self.source_model.get_data() + data[key].show() + #-------------------------------------------------------------------------- + + def set_filter(self, dictfilter=None): + """Set table dict filter""" + self.dictfilter = dictfilter + + +class CollectionsEditorWidget(QWidget): + """Dictionary Editor Widget""" + def __init__(self, parent, data, readonly=False, title="", remote=False): + QWidget.__init__(self, parent) + if remote: + self.editor = RemoteCollectionsEditorTableView(self, data, readonly) + else: + self.editor = CollectionsEditorTableView(self, data, readonly, + title) + + toolbar = SpyderToolbar(parent=None, title='Editor toolbar') + toolbar.setStyleSheet(str(PANES_TOOLBAR_STYLESHEET)) + + for item in self.editor.menu_actions: + if item is not None: + toolbar.addAction(item) + + # Update the toolbar actions state + self.editor.refresh_menu() + layout = QVBoxLayout() + layout.addWidget(toolbar) + layout.addWidget(self.editor) + self.setLayout(layout) + + def set_data(self, data): + """Set DictEditor data""" + self.editor.set_data(data) + + def get_title(self): + """Get model title""" + return self.editor.source_model.title + + +class CollectionsEditor(BaseDialog): + """Collections Editor Dialog""" + def __init__(self, parent=None): + super().__init__(parent) + + # 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.data_copy = None + self.widget = None + self.btn_save_and_close = None + self.btn_close = None + + def setup(self, data, title='', readonly=False, remote=False, + icon=None, parent=None): + """Setup editor.""" + if isinstance(data, (dict, set)): + # dictionary, set + self.data_copy = data.copy() + datalen = len(data) + elif isinstance(data, (tuple, list)): + # list, tuple + self.data_copy = data[:] + datalen = len(data) + else: + # unknown object + import copy + try: + self.data_copy = copy.deepcopy(data) + except NotImplementedError: + self.data_copy = copy.copy(data) + except (TypeError, AttributeError): + readonly = True + self.data_copy = data + datalen = len(get_object_attrs(data)) + + # If the copy has a different type, then do not allow editing, because + # this would change the type after saving; cf. spyder-ide/spyder#6936. + if type(self.data_copy) != type(data): + readonly = True + + self.widget = CollectionsEditorWidget(self, self.data_copy, + title=title, readonly=readonly, + remote=remote) + self.widget.editor.source_model.sig_setting_data.connect( + self.save_and_close_enable) + layout = QVBoxLayout() + layout.addWidget(self.widget) + self.setLayout(layout) + + # Buttons configuration + btn_layout = QHBoxLayout() + btn_layout.setContentsMargins(4, 4, 4, 4) + btn_layout.addStretch() + + if not readonly: + self.btn_save_and_close = QPushButton(_('Save and Close')) + self.btn_save_and_close.setDisabled(True) + self.btn_save_and_close.clicked.connect(self.accept) + btn_layout.addWidget(self.btn_save_and_close) + + self.btn_close = QPushButton(_('Close')) + self.btn_close.setAutoDefault(True) + self.btn_close.setDefault(True) + self.btn_close.clicked.connect(self.reject) + btn_layout.addWidget(self.btn_close) + + layout.addLayout(btn_layout) + + self.setWindowTitle(self.widget.get_title()) + if icon is None: + self.setWindowIcon(ima.icon('dictedit')) + + if sys.platform == 'darwin': + # See spyder-ide/spyder#9051 + self.setWindowFlags(Qt.Tool) + else: + # Make the dialog act as a window + self.setWindowFlags(Qt.Window) + + @Slot() + def save_and_close_enable(self): + """Handle the data change event to enable the save and close button.""" + if self.btn_save_and_close: + self.btn_save_and_close.setEnabled(True) + self.btn_save_and_close.setAutoDefault(True) + self.btn_save_and_close.setDefault(True) + + def get_value(self): + """Return modified copy of dictionary or list""" + # It is import to avoid accessing Qt C++ object as it has probably + # already been destroyed, due to the Qt.WA_DeleteOnClose attribute + return self.data_copy + + +#============================================================================== +# Remote versions of CollectionsDelegate and CollectionsEditorTableView +#============================================================================== +class RemoteCollectionsDelegate(CollectionsDelegate): + """CollectionsEditor Item Delegate""" + def __init__(self, parent=None): + CollectionsDelegate.__init__(self, parent) + + def get_value(self, index): + if index.isValid(): + source_index = index.model().mapToSource(index) + name = source_index.model().keys[source_index.row()] + return self.parent().get_value(name) + + def set_value(self, index, value): + if index.isValid(): + source_index = index.model().mapToSource(index) + name = source_index.model().keys[source_index.row()] + self.parent().new_value(name, value) + + +class RemoteCollectionsEditorTableView(BaseTableView): + """DictEditor table view""" + def __init__(self, parent, data, shellwidget=None, remote_editing=False, + create_menu=False): + BaseTableView.__init__(self, parent) + + self.shellwidget = shellwidget + self.var_properties = {} + self.dictfilter = None + self.delegate = None + self.readonly = False + self.finder = None + + self.source_model = CollectionsModel( + self, data, names=True, + minmax=self.get_conf('minmax'), + remote=True) + + self.horizontalHeader().sectionClicked.connect( + self.source_model.load_all) + + self.proxy_model = CollectionsCustomSortFilterProxy(self) + self.model = self.proxy_model + + self.proxy_model.setSourceModel(self.source_model) + self.proxy_model.setDynamicSortFilter(True) + self.proxy_model.setFilterKeyColumn(0) # Col 0 for Name + self.proxy_model.setFilterCaseSensitivity(Qt.CaseInsensitive) + self.proxy_model.setSortRole(Qt.UserRole) + self.setModel(self.proxy_model) + + self.hideColumn(4) # Column 4 for Score + + self.delegate = RemoteCollectionsDelegate(self) + self.delegate.sig_free_memory_requested.connect( + self.sig_free_memory_requested) + self.delegate.sig_editor_creation_started.connect( + self.sig_editor_creation_started) + self.delegate.sig_editor_shown.connect(self.sig_editor_shown) + self.setItemDelegate(self.delegate) + + self.setup_table() + + if create_menu: + self.menu = self.setup_menu() + + # ------ Remote/local API ------------------------------------------------- + def get_value(self, name): + """Get the value of a variable""" + value = self.shellwidget.get_value(name) + return value + + def new_value(self, name, value): + """Create new value in data""" + try: + self.shellwidget.set_value(name, value) + except TypeError as e: + QMessageBox.critical(self, _("Error"), + "TypeError: %s" % to_text_string(e)) + self.shellwidget.refresh_namespacebrowser() + + def remove_values(self, names): + """Remove values from data""" + for name in names: + self.shellwidget.remove_value(name) + self.shellwidget.refresh_namespacebrowser() + + def copy_value(self, orig_name, new_name): + """Copy value""" + self.shellwidget.copy_value(orig_name, new_name) + self.shellwidget.refresh_namespacebrowser() + + def is_list(self, name): + """Return True if variable is a list, a tuple or a set""" + return self.var_properties[name]['is_list'] + + def is_dict(self, name): + """Return True if variable is a dictionary""" + return self.var_properties[name]['is_dict'] + + def get_len(self, name): + """Return sequence length""" + return self.var_properties[name]['len'] + + def is_array(self, name): + """Return True if variable is a NumPy array""" + return self.var_properties[name]['is_array'] + + def is_image(self, name): + """Return True if variable is a PIL.Image image""" + return self.var_properties[name]['is_image'] + + def is_data_frame(self, name): + """Return True if variable is a DataFrame""" + return self.var_properties[name]['is_data_frame'] + + def is_series(self, name): + """Return True if variable is a Series""" + return self.var_properties[name]['is_series'] + + def get_array_shape(self, name): + """Return array's shape""" + return self.var_properties[name]['array_shape'] + + def get_array_ndim(self, name): + """Return array's ndim""" + return self.var_properties[name]['array_ndim'] + + def plot(self, name, funcname): + """Plot item""" + sw = self.shellwidget + sw.execute("%%varexp --%s %s" % (funcname, name)) + + def imshow(self, name): + """Show item's image""" + sw = self.shellwidget + sw.execute("%%varexp --imshow %s" % name) + + def show_image(self, name): + """Show image (item is a PIL image)""" + command = "%s.show()" % name + sw = self.shellwidget + sw.execute(command) + + # ------ Other ------------------------------------------------------------ + def setup_menu(self): + """Setup context menu.""" + menu = BaseTableView.setup_menu(self) + return menu + + def refresh_menu(self): + if self.var_properties: + super().refresh_menu() + + def set_regex(self, regex=None, reset=False): + """Update the regex text for the variable finder.""" + if reset or self.finder is None or not self.finder.text(): + text = '' + else: + text = self.finder.text().replace(' ', '').lower() + + self.proxy_model.set_filter(text) + self.source_model.update_search_letters(text) + + if text: + # TODO: Use constants for column numbers + self.sortByColumn(4, Qt.DescendingOrder) # Col 4 for index + + self.last_regex = regex + + def next_row(self): + """Move to next row from currently selected row.""" + row = self.currentIndex().row() + rows = self.proxy_model.rowCount() + if row + 1 == rows: + row = -1 + self.selectRow(row + 1) + + def previous_row(self): + """Move to previous row from currently selected row.""" + row = self.currentIndex().row() + rows = self.proxy_model.rowCount() + if row == 0: + row = rows + self.selectRow(row - 1) + + +class CollectionsCustomSortFilterProxy(CustomSortFilterProxy): + """ + Custom column filter based on regex and model data. + + Reimplements 'filterAcceptsRow' to follow NamespaceBrowser model. + Reimplements 'set_filter' to allow sorting while filtering + """ + + def get_key(self, index): + """Return current key from source model.""" + source_index = self.mapToSource(index) + return self.sourceModel().get_key(source_index) + + def get_index_from_key(self, key): + """Return index using key from source model.""" + source_index = self.sourceModel().get_index_from_key(key) + return self.mapFromSource(source_index) + + def get_value(self, index): + """Return current value from source model.""" + source_index = self.mapToSource(index) + return self.sourceModel().get_value(source_index) + + def set_value(self, index, value): + """Set value in source model.""" + try: + source_index = self.mapToSource(index) + self.sourceModel().set_value(source_index, value) + except AttributeError: + # Read-only models don't have set_value method + pass + + def set_filter(self, text): + """Set regular expression for filter.""" + self.pattern = get_search_regex(text) + self.invalidateFilter() + + def filterAcceptsRow(self, row_num, parent): + """ + Qt override. + + Reimplemented from base class to allow the use of custom filtering + using to columns (name and type). + """ + model = self.sourceModel() + name = to_text_string(model.row_key(row_num)) + variable_type = to_text_string(model.row_type(row_num)) + r_name = re.search(self.pattern, name) + r_type = re.search(self.pattern, variable_type) + + if r_name is None and r_type is None: + return False + else: + return True + + def lessThan(self, left, right): + """ + Implements ordering in a natural way, as a human would sort. + This functions enables sorting of the main variable editor table, + which does not rely on 'self.sort()'. + """ + leftData = self.sourceModel().data(left) + rightData = self.sourceModel().data(right) + try: + if isinstance(leftData, str) and isinstance(rightData, str): + return natsort(leftData) < natsort(rightData) + else: + return leftData < rightData + except TypeError: + # This is needed so all the elements that cannot be compared such + # as dataframes and numpy arrays are grouped together in the + # variable explorer. For more info see spyder-ide/spyder#14527 + return True + + +# ============================================================================= +# Tests +# ============================================================================= +def get_test_data(): + """Create test data.""" + image = PIL.Image.fromarray(np.random.randint(256, size=(100, 100)), + mode='P') + testdict = {'d': 1, 'a': np.random.rand(10, 10), 'b': [1, 2]} + testdate = datetime.date(1945, 5, 8) + test_timedelta = datetime.timedelta(days=-1, minutes=42, seconds=13) + + try: + import pandas as pd + except (ModuleNotFoundError, ImportError): + test_df = None + test_timestamp = test_pd_td = test_dtindex = test_series = None + else: + test_timestamp = pd.Timestamp("1945-05-08T23:01:00.12345") + test_pd_td = pd.Timedelta(days=2193, hours=12) + test_dtindex = pd.date_range(start="1939-09-01T", + end="1939-10-06", + freq="12H") + test_series = pd.Series({"series_name": [0, 1, 2, 3, 4, 5]}) + test_df = pd.DataFrame({"string_col": ["a", "b", "c", "d"], + "int_col": [0, 1, 2, 3], + "float_col": [1.1, 2.2, 3.3, 4.4], + "bool_col": [True, False, False, True]}) + + class Foobar(object): + + def __init__(self): + self.text = "toto" + self.testdict = testdict + self.testdate = testdate + + foobar = Foobar() + return {'object': foobar, + 'module': np, + 'str': 'kjkj kj k j j kj k jkj', + 'unicode': to_text_string('éù', 'utf-8'), + 'list': [1, 3, [sorted, 5, 6], 'kjkj', None], + 'set': {1, 2, 1, 3, None, 'A', 'B', 'C', True, False}, + 'tuple': ([1, testdate, testdict, test_timedelta], 'kjkj', None), + 'dict': testdict, + 'float': 1.2233, + 'int': 223, + 'bool': True, + 'array': np.random.rand(10, 10).astype(np.int64), + 'masked_array': np.ma.array([[1, 0], [1, 0]], + mask=[[True, False], [False, False]]), + '1D-array': np.linspace(-10, 10).astype(np.float16), + '3D-array': np.random.randint(2, size=(5, 5, 5)).astype(np.bool_), + 'empty_array': np.array([]), + 'image': image, + 'date': testdate, + 'datetime': datetime.datetime(1945, 5, 8, 23, 1, 0, int(1.5e5)), + 'timedelta': test_timedelta, + 'complex': 2+1j, + 'complex64': np.complex64(2+1j), + 'complex128': np.complex128(9j), + 'int8_scalar': np.int8(8), + 'int16_scalar': np.int16(16), + 'int32_scalar': np.int32(32), + 'int64_scalar': np.int64(64), + 'float16_scalar': np.float16(16), + 'float32_scalar': np.float32(32), + 'float64_scalar': np.float64(64), + 'bool_scalar': np.bool(8), + 'bool__scalar': np.bool_(8), + 'timestamp': test_timestamp, + 'timedelta_pd': test_pd_td, + 'datetimeindex': test_dtindex, + 'series': test_series, + 'ddataframe': test_df, + 'None': None, + 'unsupported1': np.arccos, + 'unsupported2': np.cast, + # Test for spyder-ide/spyder#3518. + 'big_struct_array': np.zeros(1000, dtype=[('ID', 'f8'), + ('param1', 'f8', 5000)]), + } + + +def editor_test(): + """Test Collections editor.""" + dialog = CollectionsEditor() + dialog.setup(get_test_data()) + dialog.show() + + +def remote_editor_test(): + """Test remote collections editor.""" + from spyder.config.manager import CONF + from spyder_kernels.utils.nsview import (make_remote_view, + REMOTE_SETTINGS) + + settings = {} + for name in REMOTE_SETTINGS: + settings[name] = CONF.get('variable_explorer', name) + + remote = make_remote_view(get_test_data(), settings) + dialog = CollectionsEditor() + dialog.setup(remote, remote=True) + dialog.show() + + +if __name__ == "__main__": + from spyder.utils.qthelpers import qapplication + + app = qapplication() # analysis:ignore + editor_test() + remote_editor_test() + app.exec_() diff --git a/spyder/widgets/colors.py b/spyder/widgets/colors.py index ddf45e791ad..57b5f9cfc2a 100644 --- a/spyder/widgets/colors.py +++ b/spyder/widgets/colors.py @@ -1,95 +1,95 @@ -# -*- coding: utf-8 -*- -# -# Copyright © Spyder Project Contributors -# Licensed under the terms of the MIT License -# (see spyder/__init__.py for details) - -# Third party imports -from qtpy.QtCore import Property, QSize, Signal, Slot -from qtpy.QtGui import QColor, QIcon, QPixmap -from qtpy.QtWidgets import QColorDialog, QHBoxLayout, QLineEdit, QToolButton - -# Local imports -from spyder.py3compat import is_text_string - - -class ColorButton(QToolButton): - """ - Color choosing push button - """ - colorChanged = Signal(QColor) - - def __init__(self, parent=None): - QToolButton.__init__(self, parent) - self.setFixedSize(20, 20) - self.setIconSize(QSize(12, 12)) - self.clicked.connect(self.choose_color) - self._color = QColor() - - def choose_color(self): - color = QColorDialog.getColor(self._color, self.parentWidget(), - 'Select Color', - QColorDialog.ShowAlphaChannel) - if color.isValid(): - self.set_color(color) - - def get_color(self): - return self._color - - @Slot(QColor) - def set_color(self, color): - if color != self._color: - self._color = color - self.colorChanged.emit(self._color) - pixmap = QPixmap(self.iconSize()) - pixmap.fill(color) - self.setIcon(QIcon(pixmap)) - - color = Property("QColor", get_color, set_color) - - -def text_to_qcolor(text): - """ - Create a QColor from specified string - Avoid warning from Qt when an invalid QColor is instantiated - """ - color = QColor() - text = str(text) - if not is_text_string(text): - return color - if text.startswith('#') and len(text)==7: - correct = '#0123456789abcdef' - for char in text: - if char.lower() not in correct: - return color - elif text not in list(QColor.colorNames()): - return color - color.setNamedColor(text) - return color - - -class ColorLayout(QHBoxLayout): - """Color-specialized QLineEdit layout""" - def __init__(self, color, parent=None): - QHBoxLayout.__init__(self) - assert isinstance(color, QColor) - self.lineedit = QLineEdit(color.name(), parent) - fm = self.lineedit.fontMetrics() - self.lineedit.setMinimumWidth(int(fm.width(color.name()) * 1.2)) - self.lineedit.textChanged.connect(self.update_color) - self.addWidget(self.lineedit) - self.colorbtn = ColorButton(parent) - self.colorbtn.color = color - self.colorbtn.colorChanged.connect(self.update_text) - self.addWidget(self.colorbtn) - - def update_color(self, text): - color = text_to_qcolor(text) - if color.isValid(): - self.colorbtn.color = color - - def update_text(self, color): - self.lineedit.setText(color.name()) - - def text(self): - return self.lineedit.text() +# -*- coding: utf-8 -*- +# +# Copyright © Spyder Project Contributors +# Licensed under the terms of the MIT License +# (see spyder/__init__.py for details) + +# Third party imports +from qtpy.QtCore import Property, QSize, Signal, Slot +from qtpy.QtGui import QColor, QIcon, QPixmap +from qtpy.QtWidgets import QColorDialog, QHBoxLayout, QLineEdit, QToolButton + +# Local imports +from spyder.py3compat import is_text_string + + +class ColorButton(QToolButton): + """ + Color choosing push button + """ + colorChanged = Signal(QColor) + + def __init__(self, parent=None): + QToolButton.__init__(self, parent) + self.setFixedSize(20, 20) + self.setIconSize(QSize(12, 12)) + self.clicked.connect(self.choose_color) + self._color = QColor() + + def choose_color(self): + color = QColorDialog.getColor(self._color, self.parentWidget(), + 'Select Color', + QColorDialog.ShowAlphaChannel) + if color.isValid(): + self.set_color(color) + + def get_color(self): + return self._color + + @Slot(QColor) + def set_color(self, color): + if color != self._color: + self._color = color + self.colorChanged.emit(self._color) + pixmap = QPixmap(self.iconSize()) + pixmap.fill(color) + self.setIcon(QIcon(pixmap)) + + color = Property("QColor", get_color, set_color) + + +def text_to_qcolor(text): + """ + Create a QColor from specified string + Avoid warning from Qt when an invalid QColor is instantiated + """ + color = QColor() + text = str(text) + if not is_text_string(text): + return color + if text.startswith('#') and len(text)==7: + correct = '#0123456789abcdef' + for char in text: + if char.lower() not in correct: + return color + elif text not in list(QColor.colorNames()): + return color + color.setNamedColor(text) + return color + + +class ColorLayout(QHBoxLayout): + """Color-specialized QLineEdit layout""" + def __init__(self, color, parent=None): + QHBoxLayout.__init__(self) + assert isinstance(color, QColor) + self.lineedit = QLineEdit(color.name(), parent) + fm = self.lineedit.fontMetrics() + self.lineedit.setMinimumWidth(int(fm.width(color.name()) * 1.2)) + self.lineedit.textChanged.connect(self.update_color) + self.addWidget(self.lineedit) + self.colorbtn = ColorButton(parent) + self.colorbtn.color = color + self.colorbtn.colorChanged.connect(self.update_text) + self.addWidget(self.colorbtn) + + def update_color(self, text): + color = text_to_qcolor(text) + if color.isValid(): + self.colorbtn.color = color + + def update_text(self, color): + self.lineedit.setText(color.name()) + + def text(self): + return self.lineedit.text() diff --git a/spyder/widgets/comboboxes.py b/spyder/widgets/comboboxes.py index b6d1cbe864b..ba3037d2691 100644 --- a/spyder/widgets/comboboxes.py +++ b/spyder/widgets/comboboxes.py @@ -1,418 +1,418 @@ -# -*- coding: utf-8 -*- -# -# Copyright © Spyder Project Contributors -# Licensed under the terms of the MIT License -# (see spyder/__init__.py for details) - -"""Customized combobox widgets.""" - -# pylint: disable=C0103 -# pylint: disable=R0903 -# pylint: disable=R0911 -# pylint: disable=R0201 - - -# Standard library imports -import glob -import os -import os.path as osp - -# Third party imports -from qtpy.QtCore import QEvent, Qt, QTimer, QUrl, Signal, QSize -from qtpy.QtGui import QFont -from qtpy.QtWidgets import (QComboBox, QCompleter, QLineEdit, - QSizePolicy, QToolTip) - -# Local imports -from spyder.config.base import _ -from spyder.py3compat import to_text_string -from spyder.utils.stylesheet import APP_STYLESHEET -from spyder.widgets.helperwidgets import IconLineEdit - - -class BaseComboBox(QComboBox): - """Editable combo box base class""" - valid = Signal(bool, bool) - sig_tab_pressed = Signal(bool) - - sig_resized = Signal(QSize, QSize) - """ - This signal is emitted to inform the widget has been resized. - - Parameters - ---------- - size: QSize - The new size of the widget. - old_size: QSize - The previous size of the widget. - """ - - def __init__(self, parent): - QComboBox.__init__(self, parent) - self.setEditable(True) - self.setCompleter(QCompleter(self)) - self.selected_text = self.currentText() - - # --- Qt overrides - def event(self, event): - """Qt Override. - - Filter tab keys and process double tab keys. - """ - - # Type check: Prevent error in PySide where 'event' may be of type - # QtGui.QPainter (for whatever reason). - if not isinstance(event, QEvent): - return True - - if (event.type() == QEvent.KeyPress) and (event.key() == Qt.Key_Tab): - self.sig_tab_pressed.emit(True) - return True - return QComboBox.event(self, event) - - def keyPressEvent(self, event): - """Qt Override. - - Handle key press events. - """ - if event.key() == Qt.Key_Return or event.key() == Qt.Key_Enter: - if self.add_current_text_if_valid(): - self.selected() - self.hide_completer() - elif event.key() == Qt.Key_Escape: - self.set_current_text(self.selected_text) - self.hide_completer() - else: - QComboBox.keyPressEvent(self, event) - - def resizeEvent(self, event): - """ - Emit a resize signal for widgets that need to adapt its size. - """ - super().resizeEvent(event) - self.sig_resized.emit(event.size(), event.oldSize()) - - # --- Own methods - def is_valid(self, qstr): - """ - Return True if string is valid - Return None if validation can't be done - """ - pass - - def selected(self): - """Action to be executed when a valid item has been selected""" - self.valid.emit(True, True) - - def add_text(self, text): - """Add text to combo box: add a new item if text is not found in - combo box items.""" - index = self.findText(text) - while index != -1: - self.removeItem(index) - index = self.findText(text) - self.insertItem(0, text) - index = self.findText('') - if index != -1: - self.removeItem(index) - self.insertItem(0, '') - if text != '': - self.setCurrentIndex(1) - else: - self.setCurrentIndex(0) - else: - self.setCurrentIndex(0) - - def set_current_text(self, text): - """Sets the text of the QLineEdit of the QComboBox.""" - self.lineEdit().setText(to_text_string(text)) - - def add_current_text(self): - """Add current text to combo box history (convenient method)""" - text = self.currentText() - self.add_text(text) - - def add_current_text_if_valid(self): - """Add current text to combo box history if valid""" - valid = self.is_valid(self.currentText()) - if valid or valid is None: - self.add_current_text() - return True - else: - self.set_current_text(self.selected_text) - - def hide_completer(self): - """Hides the completion widget.""" - self.setCompleter(QCompleter([], self)) - - -class PatternComboBox(BaseComboBox): - """Search pattern combo box""" - - def __init__(self, parent, items=None, tip=None, - adjust_to_minimum=True, id_=None): - BaseComboBox.__init__(self, parent) - if hasattr(self.lineEdit(), 'setClearButtonEnabled'): # only Qt >= 5.2 - self.lineEdit().setClearButtonEnabled(True) - if adjust_to_minimum: - self.setSizeAdjustPolicy(QComboBox.AdjustToMinimumContentsLength) - self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) - if items is not None: - self.addItems(items) - if tip is not None: - self.setToolTip(tip) - if id_ is not None: - self.ID = id_ - - -class EditableComboBox(BaseComboBox): - """ - Editable combo box + Validate - """ - - def __init__(self, parent): - BaseComboBox.__init__(self, parent) - self.font = QFont() - self.selected_text = self.currentText() - - # Widget setup - self.setSizeAdjustPolicy(QComboBox.AdjustToMinimumContentsLength) - - # Signals - self.editTextChanged.connect(self.validate) - self.tips = {True: _("Press enter to validate this entry"), - False: _('This entry is incorrect')} - - def show_tip(self, tip=""): - """Show tip""" - QToolTip.showText(self.mapToGlobal(self.pos()), tip, self) - - def selected(self): - """Action to be executed when a valid item has been selected""" - BaseComboBox.selected(self) - self.selected_text = self.currentText() - - def validate(self, qstr, editing=True): - """Validate entered path""" - if self.selected_text == qstr and qstr != '': - self.valid.emit(True, True) - return - - valid = self.is_valid(qstr) - if editing: - if valid: - self.valid.emit(True, False) - else: - self.valid.emit(False, False) - - -class PathComboBox(EditableComboBox): - """ - QComboBox handling path locations - """ - open_dir = Signal(str) - - def __init__(self, parent, adjust_to_contents=False, id_=None): - EditableComboBox.__init__(self, parent) - - # Replace the default lineedit by a custom one with icon display - lineedit = IconLineEdit(self) - - # Widget setup - if adjust_to_contents: - self.setSizeAdjustPolicy(QComboBox.AdjustToContents) - else: - self.setSizeAdjustPolicy(QComboBox.AdjustToMinimumContentsLength) - self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) - self.tips = {True: _("Press enter to validate this path"), - False: ''} - self.setLineEdit(lineedit) - - # Signals - self.highlighted.connect(self.add_tooltip_to_highlighted_item) - self.sig_tab_pressed.connect(self.tab_complete) - self.valid.connect(lineedit.update_status) - - if id_ is not None: - self.ID = id_ - - # --- Qt overrides - def focusInEvent(self, event): - """Handle focus in event restoring to display the status icon.""" - show_status = getattr(self.lineEdit(), 'show_status_icon', None) - if show_status: - show_status() - QComboBox.focusInEvent(self, event) - - def focusOutEvent(self, event): - """Handle focus out event restoring the last valid selected path.""" - # Calling asynchronously the 'add_current_text' to avoid crash - # https://groups.google.com/group/spyderlib/browse_thread/thread/2257abf530e210bd - if not self.is_valid(): - lineedit = self.lineEdit() - QTimer.singleShot(50, lambda: lineedit.setText(self.selected_text)) - - hide_status = getattr(self.lineEdit(), 'hide_status_icon', None) - if hide_status: - hide_status() - QComboBox.focusOutEvent(self, event) - - # --- Own methods - def _complete_options(self): - """Find available completion options.""" - text = to_text_string(self.currentText()) - opts = glob.glob(text + "*") - opts = sorted([opt for opt in opts if osp.isdir(opt)]) - - completer = QCompleter(opts, self) - qss = str(APP_STYLESHEET) - completer.popup().setStyleSheet(qss) - self.setCompleter(completer) - - return opts - - def tab_complete(self): - """ - If there is a single option available one tab completes the option. - """ - opts = self._complete_options() - if len(opts) == 1: - self.set_current_text(opts[0] + os.sep) - self.hide_completer() - else: - self.completer().complete() - - def is_valid(self, qstr=None): - """Return True if string is valid""" - if qstr is None: - qstr = self.currentText() - return osp.isdir(to_text_string(qstr)) - - def selected(self): - """Action to be executed when a valid item has been selected""" - self.selected_text = self.currentText() - self.valid.emit(True, True) - self.open_dir.emit(self.selected_text) - - def add_current_text(self): - """ - Add current text to combo box history (convenient method). - If path ends in os separator ("\" windows, "/" unix) remove it. - """ - text = self.currentText() - if osp.isdir(text) and text: - if text[-1] == os.sep: - text = text[:-1] - self.add_text(text) - - def add_tooltip_to_highlighted_item(self, index): - """ - Add a tooltip showing the full path of the currently highlighted item - of the PathComboBox. - """ - self.setItemData(index, self.itemText(index), Qt.ToolTipRole) - - -class UrlComboBox(PathComboBox): - """ - QComboBox handling urls - """ - def __init__(self, parent, adjust_to_contents=False, id_=None): - PathComboBox.__init__(self, parent, adjust_to_contents) - line_edit = QLineEdit(self) - self.setLineEdit(line_edit) - self.editTextChanged.disconnect(self.validate) - - if id_ is not None: - self.ID = id_ - - def is_valid(self, qstr=None): - """Return True if string is valid""" - if qstr is None: - qstr = self.currentText() - return QUrl(qstr).isValid() - - -class FileComboBox(PathComboBox): - """ - QComboBox handling File paths - """ - def __init__(self, parent=None, adjust_to_contents=False, - default_line_edit=False): - PathComboBox.__init__(self, parent, adjust_to_contents) - - if default_line_edit: - line_edit = QLineEdit(self) - self.setLineEdit(line_edit) - - # Widget setup - if adjust_to_contents: - self.setSizeAdjustPolicy(QComboBox.AdjustToContents) - else: - self.setSizeAdjustPolicy(QComboBox.AdjustToContentsOnFirstShow) - self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) - - def is_valid(self, qstr=None): - """Return True if string is valid.""" - if qstr is None: - qstr = self.currentText() - valid = (osp.isfile(to_text_string(qstr)) or - osp.isdir(to_text_string(qstr))) - return valid - - def tab_complete(self): - """ - If there is a single option available one tab completes the option. - """ - opts = self._complete_options() - if len(opts) == 1: - text = opts[0] - if osp.isdir(text): - text = text + os.sep - self.set_current_text(text) - self.hide_completer() - else: - self.completer().complete() - - def _complete_options(self): - """Find available completion options.""" - text = to_text_string(self.currentText()) - opts = glob.glob(text + "*") - opts = sorted([opt for opt in opts - if osp.isdir(opt) or osp.isfile(opt)]) - - completer = QCompleter(opts, self) - qss = str(APP_STYLESHEET) - completer.popup().setStyleSheet(qss) - self.setCompleter(completer) - - return opts - - -def is_module_or_package(path): - """Return True if path is a Python module/package""" - is_module = osp.isfile(path) and osp.splitext(path)[1] in ('.py', '.pyw') - is_package = osp.isdir(path) and osp.isfile(osp.join(path, '__init__.py')) - return is_module or is_package - - -class PythonModulesComboBox(PathComboBox): - """ - QComboBox handling Python modules or packages path - (i.e. .py, .pyw files *and* directories containing __init__.py) - """ - def __init__(self, parent, adjust_to_contents=False, id_=None): - PathComboBox.__init__(self, parent, adjust_to_contents) - if id_ is not None: - self.ID = id_ - - def is_valid(self, qstr=None): - """Return True if string is valid""" - if qstr is None: - qstr = self.currentText() - return is_module_or_package(to_text_string(qstr)) - - def selected(self): - """Action to be executed when a valid item has been selected""" - EditableComboBox.selected(self) - self.open_dir.emit(self.currentText()) +# -*- coding: utf-8 -*- +# +# Copyright © Spyder Project Contributors +# Licensed under the terms of the MIT License +# (see spyder/__init__.py for details) + +"""Customized combobox widgets.""" + +# pylint: disable=C0103 +# pylint: disable=R0903 +# pylint: disable=R0911 +# pylint: disable=R0201 + + +# Standard library imports +import glob +import os +import os.path as osp + +# Third party imports +from qtpy.QtCore import QEvent, Qt, QTimer, QUrl, Signal, QSize +from qtpy.QtGui import QFont +from qtpy.QtWidgets import (QComboBox, QCompleter, QLineEdit, + QSizePolicy, QToolTip) + +# Local imports +from spyder.config.base import _ +from spyder.py3compat import to_text_string +from spyder.utils.stylesheet import APP_STYLESHEET +from spyder.widgets.helperwidgets import IconLineEdit + + +class BaseComboBox(QComboBox): + """Editable combo box base class""" + valid = Signal(bool, bool) + sig_tab_pressed = Signal(bool) + + sig_resized = Signal(QSize, QSize) + """ + This signal is emitted to inform the widget has been resized. + + Parameters + ---------- + size: QSize + The new size of the widget. + old_size: QSize + The previous size of the widget. + """ + + def __init__(self, parent): + QComboBox.__init__(self, parent) + self.setEditable(True) + self.setCompleter(QCompleter(self)) + self.selected_text = self.currentText() + + # --- Qt overrides + def event(self, event): + """Qt Override. + + Filter tab keys and process double tab keys. + """ + + # Type check: Prevent error in PySide where 'event' may be of type + # QtGui.QPainter (for whatever reason). + if not isinstance(event, QEvent): + return True + + if (event.type() == QEvent.KeyPress) and (event.key() == Qt.Key_Tab): + self.sig_tab_pressed.emit(True) + return True + return QComboBox.event(self, event) + + def keyPressEvent(self, event): + """Qt Override. + + Handle key press events. + """ + if event.key() == Qt.Key_Return or event.key() == Qt.Key_Enter: + if self.add_current_text_if_valid(): + self.selected() + self.hide_completer() + elif event.key() == Qt.Key_Escape: + self.set_current_text(self.selected_text) + self.hide_completer() + else: + QComboBox.keyPressEvent(self, event) + + def resizeEvent(self, event): + """ + Emit a resize signal for widgets that need to adapt its size. + """ + super().resizeEvent(event) + self.sig_resized.emit(event.size(), event.oldSize()) + + # --- Own methods + def is_valid(self, qstr): + """ + Return True if string is valid + Return None if validation can't be done + """ + pass + + def selected(self): + """Action to be executed when a valid item has been selected""" + self.valid.emit(True, True) + + def add_text(self, text): + """Add text to combo box: add a new item if text is not found in + combo box items.""" + index = self.findText(text) + while index != -1: + self.removeItem(index) + index = self.findText(text) + self.insertItem(0, text) + index = self.findText('') + if index != -1: + self.removeItem(index) + self.insertItem(0, '') + if text != '': + self.setCurrentIndex(1) + else: + self.setCurrentIndex(0) + else: + self.setCurrentIndex(0) + + def set_current_text(self, text): + """Sets the text of the QLineEdit of the QComboBox.""" + self.lineEdit().setText(to_text_string(text)) + + def add_current_text(self): + """Add current text to combo box history (convenient method)""" + text = self.currentText() + self.add_text(text) + + def add_current_text_if_valid(self): + """Add current text to combo box history if valid""" + valid = self.is_valid(self.currentText()) + if valid or valid is None: + self.add_current_text() + return True + else: + self.set_current_text(self.selected_text) + + def hide_completer(self): + """Hides the completion widget.""" + self.setCompleter(QCompleter([], self)) + + +class PatternComboBox(BaseComboBox): + """Search pattern combo box""" + + def __init__(self, parent, items=None, tip=None, + adjust_to_minimum=True, id_=None): + BaseComboBox.__init__(self, parent) + if hasattr(self.lineEdit(), 'setClearButtonEnabled'): # only Qt >= 5.2 + self.lineEdit().setClearButtonEnabled(True) + if adjust_to_minimum: + self.setSizeAdjustPolicy(QComboBox.AdjustToMinimumContentsLength) + self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) + if items is not None: + self.addItems(items) + if tip is not None: + self.setToolTip(tip) + if id_ is not None: + self.ID = id_ + + +class EditableComboBox(BaseComboBox): + """ + Editable combo box + Validate + """ + + def __init__(self, parent): + BaseComboBox.__init__(self, parent) + self.font = QFont() + self.selected_text = self.currentText() + + # Widget setup + self.setSizeAdjustPolicy(QComboBox.AdjustToMinimumContentsLength) + + # Signals + self.editTextChanged.connect(self.validate) + self.tips = {True: _("Press enter to validate this entry"), + False: _('This entry is incorrect')} + + def show_tip(self, tip=""): + """Show tip""" + QToolTip.showText(self.mapToGlobal(self.pos()), tip, self) + + def selected(self): + """Action to be executed when a valid item has been selected""" + BaseComboBox.selected(self) + self.selected_text = self.currentText() + + def validate(self, qstr, editing=True): + """Validate entered path""" + if self.selected_text == qstr and qstr != '': + self.valid.emit(True, True) + return + + valid = self.is_valid(qstr) + if editing: + if valid: + self.valid.emit(True, False) + else: + self.valid.emit(False, False) + + +class PathComboBox(EditableComboBox): + """ + QComboBox handling path locations + """ + open_dir = Signal(str) + + def __init__(self, parent, adjust_to_contents=False, id_=None): + EditableComboBox.__init__(self, parent) + + # Replace the default lineedit by a custom one with icon display + lineedit = IconLineEdit(self) + + # Widget setup + if adjust_to_contents: + self.setSizeAdjustPolicy(QComboBox.AdjustToContents) + else: + self.setSizeAdjustPolicy(QComboBox.AdjustToMinimumContentsLength) + self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) + self.tips = {True: _("Press enter to validate this path"), + False: ''} + self.setLineEdit(lineedit) + + # Signals + self.highlighted.connect(self.add_tooltip_to_highlighted_item) + self.sig_tab_pressed.connect(self.tab_complete) + self.valid.connect(lineedit.update_status) + + if id_ is not None: + self.ID = id_ + + # --- Qt overrides + def focusInEvent(self, event): + """Handle focus in event restoring to display the status icon.""" + show_status = getattr(self.lineEdit(), 'show_status_icon', None) + if show_status: + show_status() + QComboBox.focusInEvent(self, event) + + def focusOutEvent(self, event): + """Handle focus out event restoring the last valid selected path.""" + # Calling asynchronously the 'add_current_text' to avoid crash + # https://groups.google.com/group/spyderlib/browse_thread/thread/2257abf530e210bd + if not self.is_valid(): + lineedit = self.lineEdit() + QTimer.singleShot(50, lambda: lineedit.setText(self.selected_text)) + + hide_status = getattr(self.lineEdit(), 'hide_status_icon', None) + if hide_status: + hide_status() + QComboBox.focusOutEvent(self, event) + + # --- Own methods + def _complete_options(self): + """Find available completion options.""" + text = to_text_string(self.currentText()) + opts = glob.glob(text + "*") + opts = sorted([opt for opt in opts if osp.isdir(opt)]) + + completer = QCompleter(opts, self) + qss = str(APP_STYLESHEET) + completer.popup().setStyleSheet(qss) + self.setCompleter(completer) + + return opts + + def tab_complete(self): + """ + If there is a single option available one tab completes the option. + """ + opts = self._complete_options() + if len(opts) == 1: + self.set_current_text(opts[0] + os.sep) + self.hide_completer() + else: + self.completer().complete() + + def is_valid(self, qstr=None): + """Return True if string is valid""" + if qstr is None: + qstr = self.currentText() + return osp.isdir(to_text_string(qstr)) + + def selected(self): + """Action to be executed when a valid item has been selected""" + self.selected_text = self.currentText() + self.valid.emit(True, True) + self.open_dir.emit(self.selected_text) + + def add_current_text(self): + """ + Add current text to combo box history (convenient method). + If path ends in os separator ("\" windows, "/" unix) remove it. + """ + text = self.currentText() + if osp.isdir(text) and text: + if text[-1] == os.sep: + text = text[:-1] + self.add_text(text) + + def add_tooltip_to_highlighted_item(self, index): + """ + Add a tooltip showing the full path of the currently highlighted item + of the PathComboBox. + """ + self.setItemData(index, self.itemText(index), Qt.ToolTipRole) + + +class UrlComboBox(PathComboBox): + """ + QComboBox handling urls + """ + def __init__(self, parent, adjust_to_contents=False, id_=None): + PathComboBox.__init__(self, parent, adjust_to_contents) + line_edit = QLineEdit(self) + self.setLineEdit(line_edit) + self.editTextChanged.disconnect(self.validate) + + if id_ is not None: + self.ID = id_ + + def is_valid(self, qstr=None): + """Return True if string is valid""" + if qstr is None: + qstr = self.currentText() + return QUrl(qstr).isValid() + + +class FileComboBox(PathComboBox): + """ + QComboBox handling File paths + """ + def __init__(self, parent=None, adjust_to_contents=False, + default_line_edit=False): + PathComboBox.__init__(self, parent, adjust_to_contents) + + if default_line_edit: + line_edit = QLineEdit(self) + self.setLineEdit(line_edit) + + # Widget setup + if adjust_to_contents: + self.setSizeAdjustPolicy(QComboBox.AdjustToContents) + else: + self.setSizeAdjustPolicy(QComboBox.AdjustToContentsOnFirstShow) + self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) + + def is_valid(self, qstr=None): + """Return True if string is valid.""" + if qstr is None: + qstr = self.currentText() + valid = (osp.isfile(to_text_string(qstr)) or + osp.isdir(to_text_string(qstr))) + return valid + + def tab_complete(self): + """ + If there is a single option available one tab completes the option. + """ + opts = self._complete_options() + if len(opts) == 1: + text = opts[0] + if osp.isdir(text): + text = text + os.sep + self.set_current_text(text) + self.hide_completer() + else: + self.completer().complete() + + def _complete_options(self): + """Find available completion options.""" + text = to_text_string(self.currentText()) + opts = glob.glob(text + "*") + opts = sorted([opt for opt in opts + if osp.isdir(opt) or osp.isfile(opt)]) + + completer = QCompleter(opts, self) + qss = str(APP_STYLESHEET) + completer.popup().setStyleSheet(qss) + self.setCompleter(completer) + + return opts + + +def is_module_or_package(path): + """Return True if path is a Python module/package""" + is_module = osp.isfile(path) and osp.splitext(path)[1] in ('.py', '.pyw') + is_package = osp.isdir(path) and osp.isfile(osp.join(path, '__init__.py')) + return is_module or is_package + + +class PythonModulesComboBox(PathComboBox): + """ + QComboBox handling Python modules or packages path + (i.e. .py, .pyw files *and* directories containing __init__.py) + """ + def __init__(self, parent, adjust_to_contents=False, id_=None): + PathComboBox.__init__(self, parent, adjust_to_contents) + if id_ is not None: + self.ID = id_ + + def is_valid(self, qstr=None): + """Return True if string is valid""" + if qstr is None: + qstr = self.currentText() + return is_module_or_package(to_text_string(qstr)) + + def selected(self): + """Action to be executed when a valid item has been selected""" + EditableComboBox.selected(self) + self.open_dir.emit(self.currentText()) diff --git a/spyder/widgets/dependencies.py b/spyder/widgets/dependencies.py index 188c4b165bd..0b0620aae2b 100644 --- a/spyder/widgets/dependencies.py +++ b/spyder/widgets/dependencies.py @@ -1,156 +1,156 @@ -# -*- coding: utf-8 -*- -# -# Copyright © Spyder Project Contributors -# Licensed under the terms of the MIT License -# (see spyder/__init__.py for details) - -"""Module checking Spyder runtime dependencies""" - -# Standard library imports -import sys - -# Third party imports -from qtpy.QtCore import Qt -from qtpy.QtGui import QColor -from qtpy.QtWidgets import (QApplication, QDialog, QDialogButtonBox, - QHBoxLayout, QVBoxLayout, QLabel, QPushButton, - QTreeWidget, QTreeWidgetItem) - -# Local imports -from spyder import __version__ -from spyder.config.base import _ -from spyder.dependencies import MANDATORY, OPTIONAL, PLUGIN -from spyder.utils.icon_manager import ima -from spyder.utils.palette import SpyderPalette - - -class DependenciesTreeWidget(QTreeWidget): - - def update_dependencies(self, dependencies): - self.clear() - headers = (_("Module"), _("Package name"), _(" Required "), - _(" Installed "), _("Provided features")) - self.setHeaderLabels(headers) - - # Mandatory items - mandatory_item = QTreeWidgetItem([_("Mandatory")]) - font = mandatory_item.font(0) - font.setBold(True) - mandatory_item.setFont(0, font) - - # Optional items - optional_item = QTreeWidgetItem([_("Optional")]) - optional_item.setFont(0, font) - - # Spyder plugins - spyder_plugins = QTreeWidgetItem([_("Spyder plugins")]) - spyder_plugins.setFont(0, font) - - self.addTopLevelItems([mandatory_item, optional_item, spyder_plugins]) - - for dependency in sorted(dependencies, - key=lambda x: x.modname.lower()): - item = QTreeWidgetItem([dependency.modname, - dependency.package_name, - dependency.required_version, - dependency.installed_version, - dependency.features]) - # Format content - if dependency.check(): - item.setIcon(0, ima.icon('dependency_ok')) - elif dependency.kind == OPTIONAL: - item.setIcon(0, ima.icon('dependency_warning')) - item.setForeground(2, QColor(SpyderPalette.COLOR_WARN_1)) - else: - item.setIcon(0, ima.icon('dependency_error')) - item.setForeground(2, QColor(SpyderPalette.COLOR_ERROR_1)) - - # Add to tree - if dependency.kind == OPTIONAL: - optional_item.addChild(item) - elif dependency.kind == PLUGIN: - spyder_plugins.addChild(item) - else: - mandatory_item.addChild(item) - - self.expandAll() - - def resize_columns_to_contents(self): - for col in range(self.columnCount()): - self.resizeColumnToContents(col) - - -class DependenciesDialog(QDialog): - - def __init__(self, parent): - QDialog.__init__(self, parent) - - # Widgets - self.label = QLabel(_("Optional modules are not required to run " - "Spyder but enhance its functions.")) - self.label2 = QLabel(_("Note: New dependencies or changed ones " - "will be correctly detected only after Spyder " - "is restarted.")) - self.treewidget = DependenciesTreeWidget(self) - btn = QPushButton(_("Copy to clipboard"), ) - bbox = QDialogButtonBox(QDialogButtonBox.Ok) - - # Widget setup - self.setWindowTitle("Spyder %s: %s" % (__version__, - _("Dependencies"))) - self.setWindowIcon(ima.icon('tooloptions')) - self.setModal(False) - - # Layout - hlayout = QHBoxLayout() - hlayout.addWidget(btn) - hlayout.addStretch() - hlayout.addWidget(bbox) - - vlayout = QVBoxLayout() - vlayout.addWidget(self.treewidget) - vlayout.addWidget(self.label) - vlayout.addWidget(self.label2) - vlayout.addLayout(hlayout) - - self.setLayout(vlayout) - self.resize(860, 560) - - # Signals - btn.clicked.connect(self.copy_to_clipboard) - bbox.accepted.connect(self.accept) - - def set_data(self, dependencies): - self.treewidget.update_dependencies(dependencies) - self.treewidget.resize_columns_to_contents() - - def copy_to_clipboard(self): - from spyder.dependencies import status - QApplication.clipboard().setText(status()) - - -def test(): - """Run dependency widget test""" - from spyder import dependencies - - # Test sample - dependencies.add("IPython", "IPython", "Enhanced Python interpreter", - ">=20.0") - dependencies.add("matplotlib", "matplotlib", "Interactive data plotting", - ">=1.0") - dependencies.add("sympy", "sympy", "Symbolic Mathematics", ">=10.0", - kind=OPTIONAL) - dependencies.add("foo", "foo", "Non-existent module", ">=1.0") - dependencies.add("numpy", "numpy", "Edit arrays in Variable Explorer", - ">=0.10", kind=OPTIONAL) - - from spyder.utils.qthelpers import qapplication - app = qapplication() - dlg = DependenciesDialog(None) - dlg.set_data(dependencies.DEPENDENCIES) - dlg.show() - sys.exit(dlg.exec_()) - - -if __name__ == '__main__': - test() +# -*- coding: utf-8 -*- +# +# Copyright © Spyder Project Contributors +# Licensed under the terms of the MIT License +# (see spyder/__init__.py for details) + +"""Module checking Spyder runtime dependencies""" + +# Standard library imports +import sys + +# Third party imports +from qtpy.QtCore import Qt +from qtpy.QtGui import QColor +from qtpy.QtWidgets import (QApplication, QDialog, QDialogButtonBox, + QHBoxLayout, QVBoxLayout, QLabel, QPushButton, + QTreeWidget, QTreeWidgetItem) + +# Local imports +from spyder import __version__ +from spyder.config.base import _ +from spyder.dependencies import MANDATORY, OPTIONAL, PLUGIN +from spyder.utils.icon_manager import ima +from spyder.utils.palette import SpyderPalette + + +class DependenciesTreeWidget(QTreeWidget): + + def update_dependencies(self, dependencies): + self.clear() + headers = (_("Module"), _("Package name"), _(" Required "), + _(" Installed "), _("Provided features")) + self.setHeaderLabels(headers) + + # Mandatory items + mandatory_item = QTreeWidgetItem([_("Mandatory")]) + font = mandatory_item.font(0) + font.setBold(True) + mandatory_item.setFont(0, font) + + # Optional items + optional_item = QTreeWidgetItem([_("Optional")]) + optional_item.setFont(0, font) + + # Spyder plugins + spyder_plugins = QTreeWidgetItem([_("Spyder plugins")]) + spyder_plugins.setFont(0, font) + + self.addTopLevelItems([mandatory_item, optional_item, spyder_plugins]) + + for dependency in sorted(dependencies, + key=lambda x: x.modname.lower()): + item = QTreeWidgetItem([dependency.modname, + dependency.package_name, + dependency.required_version, + dependency.installed_version, + dependency.features]) + # Format content + if dependency.check(): + item.setIcon(0, ima.icon('dependency_ok')) + elif dependency.kind == OPTIONAL: + item.setIcon(0, ima.icon('dependency_warning')) + item.setForeground(2, QColor(SpyderPalette.COLOR_WARN_1)) + else: + item.setIcon(0, ima.icon('dependency_error')) + item.setForeground(2, QColor(SpyderPalette.COLOR_ERROR_1)) + + # Add to tree + if dependency.kind == OPTIONAL: + optional_item.addChild(item) + elif dependency.kind == PLUGIN: + spyder_plugins.addChild(item) + else: + mandatory_item.addChild(item) + + self.expandAll() + + def resize_columns_to_contents(self): + for col in range(self.columnCount()): + self.resizeColumnToContents(col) + + +class DependenciesDialog(QDialog): + + def __init__(self, parent): + QDialog.__init__(self, parent) + + # Widgets + self.label = QLabel(_("Optional modules are not required to run " + "Spyder but enhance its functions.")) + self.label2 = QLabel(_("Note: New dependencies or changed ones " + "will be correctly detected only after Spyder " + "is restarted.")) + self.treewidget = DependenciesTreeWidget(self) + btn = QPushButton(_("Copy to clipboard"), ) + bbox = QDialogButtonBox(QDialogButtonBox.Ok) + + # Widget setup + self.setWindowTitle("Spyder %s: %s" % (__version__, + _("Dependencies"))) + self.setWindowIcon(ima.icon('tooloptions')) + self.setModal(False) + + # Layout + hlayout = QHBoxLayout() + hlayout.addWidget(btn) + hlayout.addStretch() + hlayout.addWidget(bbox) + + vlayout = QVBoxLayout() + vlayout.addWidget(self.treewidget) + vlayout.addWidget(self.label) + vlayout.addWidget(self.label2) + vlayout.addLayout(hlayout) + + self.setLayout(vlayout) + self.resize(860, 560) + + # Signals + btn.clicked.connect(self.copy_to_clipboard) + bbox.accepted.connect(self.accept) + + def set_data(self, dependencies): + self.treewidget.update_dependencies(dependencies) + self.treewidget.resize_columns_to_contents() + + def copy_to_clipboard(self): + from spyder.dependencies import status + QApplication.clipboard().setText(status()) + + +def test(): + """Run dependency widget test""" + from spyder import dependencies + + # Test sample + dependencies.add("IPython", "IPython", "Enhanced Python interpreter", + ">=20.0") + dependencies.add("matplotlib", "matplotlib", "Interactive data plotting", + ">=1.0") + dependencies.add("sympy", "sympy", "Symbolic Mathematics", ">=10.0", + kind=OPTIONAL) + dependencies.add("foo", "foo", "Non-existent module", ">=1.0") + dependencies.add("numpy", "numpy", "Edit arrays in Variable Explorer", + ">=0.10", kind=OPTIONAL) + + from spyder.utils.qthelpers import qapplication + app = qapplication() + dlg = DependenciesDialog(None) + dlg.set_data(dependencies.DEPENDENCIES) + dlg.show() + sys.exit(dlg.exec_()) + + +if __name__ == '__main__': + test() diff --git a/spyder/widgets/findreplace.py b/spyder/widgets/findreplace.py index 5ca74010d6d..982119cb771 100644 --- a/spyder/widgets/findreplace.py +++ b/spyder/widgets/findreplace.py @@ -1,660 +1,660 @@ -# -*- coding: utf-8 -*- -# -# Copyright © Spyder Project Contributors -# Licensed under the terms of the MIT License -# (see spyder/__init__.py for details) - -"""Find/Replace widget""" - -# pylint: disable=C0103 -# pylint: disable=R0903 -# pylint: disable=R0911 -# pylint: disable=R0201 - -# Standard library imports -import re - -# Third party imports -from qtpy.QtCore import Qt, QTimer, Signal, Slot, QEvent -from qtpy.QtGui import QTextCursor -from qtpy.QtWidgets import (QGridLayout, QHBoxLayout, QLabel, - QSizePolicy, QWidget) - -# Local imports -from spyder.config.base import _ -from spyder.config.manager import CONF -from spyder.py3compat import to_text_string -from spyder.utils.icon_manager import ima -from spyder.utils.misc import regexp_error_msg -from spyder.plugins.editor.utils.editor import TextHelper -from spyder.utils.qthelpers import create_toolbutton -from spyder.utils.sourcecode import get_eol_chars -from spyder.widgets.comboboxes import PatternComboBox - - -def is_position_sup(pos1, pos2): - """Return True is pos1 > pos2""" - return pos1 > pos2 - -def is_position_inf(pos1, pos2): - """Return True is pos1 < pos2""" - return pos1 < pos2 - - -class FindReplace(QWidget): - """Find widget""" - STYLE = {False: "background-color:'#F37E12';", - True: "", - None: "", - 'regexp_error': "background-color:'#E74C3C';", - } - TOOLTIP = {False: _("No matches"), - True: _("Search string"), - None: _("Search string"), - 'regexp_error': _("Regular expression error") - } - visibility_changed = Signal(bool) - return_shift_pressed = Signal() - return_pressed = Signal() - - def __init__(self, parent, enable_replace=False): - QWidget.__init__(self, parent) - self.enable_replace = enable_replace - self.editor = None - self.is_code_editor = None - self.setStyleSheet( - "QComboBox {" - "padding-right: 0px;" - "padding-left: 0px;" - "}") - - glayout = QGridLayout() - glayout.setContentsMargins(0, 0, 0, 0) - self.setLayout(glayout) - - self.close_button = create_toolbutton(self, triggered=self.hide, - icon=ima.icon('DialogCloseButton')) - glayout.addWidget(self.close_button, 0, 0) - - # Find layout - self.search_text = PatternComboBox(self, tip=_("Search string"), - adjust_to_minimum=False) - - self.return_shift_pressed.connect( - lambda: - self.find(changed=False, forward=False, rehighlight=False, - multiline_replace_check = False)) - - self.return_pressed.connect( - lambda: - self.find(changed=False, forward=True, rehighlight=False, - multiline_replace_check = False)) - - self.search_text.lineEdit().textEdited.connect( - self.text_has_been_edited) - - self.number_matches_text = QLabel(self) - self.replace_on = False - self.replace_text_button = create_toolbutton( - self, - toggled=self.change_replace_state, - icon=ima.icon('replace'), - tip=_("Replace text") - ) - self.previous_button = create_toolbutton(self, - triggered=self.find_previous, - icon=ima.icon('findprevious'), - tip=_("Find previous")) - self.next_button = create_toolbutton(self, - triggered=self.find_next, - icon=ima.icon('findnext'), - tip=_("Find next")) - self.next_button.clicked.connect(self.update_search_combo) - self.previous_button.clicked.connect(self.update_search_combo) - - self.re_button = create_toolbutton(self, icon=ima.icon('regex'), - tip=_("Regular expression")) - self.re_button.setCheckable(True) - self.re_button.toggled.connect(lambda state: self.find()) - - self.case_button = create_toolbutton(self, - icon=ima.icon( - "format_letter_case"), - tip=_("Case Sensitive")) - self.case_button.setCheckable(True) - self.case_button.toggled.connect(lambda state: self.find()) - - self.words_button = create_toolbutton(self, - icon=ima.icon("whole_words"), - tip=_("Whole words")) - self.words_button.setCheckable(True) - self.words_button.toggled.connect(lambda state: self.find()) - - hlayout = QHBoxLayout() - self.widgets = [self.close_button, self.search_text, - self.number_matches_text, self.replace_text_button, - self.previous_button, self.next_button, - self.re_button, self.case_button, - self.words_button] - for widget in self.widgets[1:]: - hlayout.addWidget(widget) - glayout.addLayout(hlayout, 0, 1) - - # Replace layout - replace_with = QLabel(_("Replace with:")) - self.replace_text = PatternComboBox(self, adjust_to_minimum=False, - tip=_('Replace string')) - self.replace_text.valid.connect( - lambda _: self.replace_find(focus_replace_text=True)) - self.replace_button = create_toolbutton(self, - text=_('Find next'), - icon=ima.icon('DialogApplyButton'), - triggered=self.replace_find, - text_beside_icon=True) - self.replace_sel_button = create_toolbutton(self, - text=_('In selection'), - icon=ima.icon('DialogApplyButton'), - triggered=self.replace_find_selection, - text_beside_icon=True) - self.replace_sel_button.clicked.connect(self.update_replace_combo) - self.replace_sel_button.clicked.connect(self.update_search_combo) - - self.replace_all_button = create_toolbutton(self, - text=_('All'), - icon=ima.icon('DialogApplyButton'), - triggered=self.replace_find_all, - text_beside_icon=True) - self.replace_all_button.clicked.connect(self.update_replace_combo) - self.replace_all_button.clicked.connect(self.update_search_combo) - - self.replace_layout = QHBoxLayout() - widgets = [replace_with, self.replace_text, self.replace_button, - self.replace_sel_button, self.replace_all_button] - for widget in widgets: - self.replace_layout.addWidget(widget) - glayout.addLayout(self.replace_layout, 1, 1) - self.widgets.extend(widgets) - self.replace_widgets = widgets - self.hide_replace() - - self.search_text.setTabOrder(self.search_text, self.replace_text) - - self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) - - self.shortcuts = self.create_shortcuts(parent) - - self.highlight_timer = QTimer(self) - self.highlight_timer.setSingleShot(True) - self.highlight_timer.setInterval(300) - self.highlight_timer.timeout.connect(self.highlight_matches) - self.search_text.installEventFilter(self) - - def eventFilter(self, widget, event): - """Event filter for search_text widget. - - Emits signals when presing Enter and Shift+Enter. - This signals are used for search forward and backward. - Also, a crude hack to get tab working in the Find/Replace boxes. - """ - - # Type check: Prevent error in PySide where 'event' may be of type - # QtGui.QPainter (for whatever reason). - if not isinstance(event, QEvent): - return True - - if event.type() == QEvent.KeyPress: - key = event.key() - shift = event.modifiers() & Qt.ShiftModifier - - if key == Qt.Key_Return: - if shift: - self.return_shift_pressed.emit() - else: - self.return_pressed.emit() - - if key == Qt.Key_Tab: - if self.search_text.hasFocus(): - self.replace_text.set_current_text( - self.search_text.currentText()) - self.focusNextChild() - - return super(FindReplace, self).eventFilter(widget, event) - - def create_shortcuts(self, parent): - """Create shortcuts for this widget""" - # Configurable - findnext = CONF.config_shortcut( - self.find_next, - context='find_replace', - name='Find next', - parent=parent) - - findprev = CONF.config_shortcut( - self.find_previous, - context='find_replace', - name='Find previous', - parent=parent) - - togglefind = CONF.config_shortcut( - self.show, - context='find_replace', - name='Find text', - parent=parent) - - togglereplace = CONF.config_shortcut( - self.show_replace, - context='find_replace', - name='Replace text', - parent=parent) - - hide = CONF.config_shortcut( - self.hide, - context='find_replace', - name='hide find and replace', - parent=self) - - return [findnext, findprev, togglefind, togglereplace, hide] - - def get_shortcut_data(self): - """ - Returns shortcut data, a list of tuples (shortcut, text, default) - shortcut (QShortcut or QAction instance) - text (string): action/shortcut description - default (string): default key sequence - """ - return [sc.data for sc in self.shortcuts] - - def update_search_combo(self): - self.search_text.lineEdit().returnPressed.emit() - - def update_replace_combo(self): - self.replace_text.lineEdit().returnPressed.emit() - - @Slot(bool) - def toggle_highlighting(self, state): - """Toggle the 'highlight all results' feature""" - if self.editor is not None: - if state: - self.highlight_matches() - else: - self.clear_matches() - - def show(self, hide_replace=True): - """Overrides Qt Method""" - QWidget.show(self) - self.visibility_changed.emit(True) - self.change_number_matches() - if self.editor is not None: - if hide_replace: - if self.replace_widgets[0].isVisible(): - self.hide_replace() - text = self.editor.get_selected_text() - # When selecting several lines, and replace box is activated the - # text won't be replaced for the selection - if hide_replace or len(text.splitlines()) <= 1: - highlighted = True - # If no text is highlighted for search, use whatever word is - # under the cursor - if not text: - highlighted = False - try: - cursor = self.editor.textCursor() - cursor.select(QTextCursor.WordUnderCursor) - text = to_text_string(cursor.selectedText()) - except AttributeError: - # We can't do this for all widgets, e.g. WebView's - pass - - # Now that text value is sorted out, use it for the search - if text and not self.search_text.currentText() or highlighted: - self.search_text.setEditText(text) - self.search_text.lineEdit().selectAll() - self.refresh() - else: - self.search_text.lineEdit().selectAll() - self.search_text.setFocus() - - @Slot() - def replace_widget(self, replace_on): - """Show and hide replace widget""" - if replace_on: - self.show_replace() - else: - self.hide_replace() - - def change_replace_state(self): - """Handle the change of the replace state widget.""" - self.replace_on = not self.replace_on - self.replace_text_button.setChecked(self.replace_on) - self.replace_widget(self.replace_on) - - def hide(self): - """Overrides Qt Method""" - for widget in self.replace_widgets: - widget.hide() - QWidget.hide(self) - self.visibility_changed.emit(False) - if self.editor is not None: - self.editor.setFocus() - self.clear_matches() - - def show_replace(self): - """Show replace widgets""" - if self.enable_replace: - self.show(hide_replace=False) - for widget in self.replace_widgets: - widget.show() - - def hide_replace(self): - """Hide replace widgets""" - for widget in self.replace_widgets: - widget.hide() - - def refresh(self): - """Refresh widget""" - if self.isHidden(): - if self.editor is not None: - self.clear_matches() - return - state = self.editor is not None - for widget in self.widgets: - widget.setEnabled(state) - if state: - self.find() - - def set_editor(self, editor, refresh=True): - """ - Set associated editor/web page: - codeeditor.base.TextEditBaseWidget - browser.WebView - """ - self.editor = editor - # Note: This is necessary to test widgets/editor.py - # in Qt builds that don't have web widgets - try: - from qtpy.QtWebEngineWidgets import QWebEngineView - except ImportError: - QWebEngineView = type(None) - self.words_button.setVisible(not isinstance(editor, QWebEngineView)) - self.re_button.setVisible(not isinstance(editor, QWebEngineView)) - from spyder.plugins.editor.widgets.codeeditor import CodeEditor - self.is_code_editor = isinstance(editor, CodeEditor) - if refresh: - self.refresh() - if self.isHidden() and editor is not None: - self.clear_matches() - - @Slot() - def find_next(self, set_focus=True): - """Find next occurrence""" - state = self.find(changed=False, forward=True, rehighlight=False, - multiline_replace_check=False) - if set_focus: - self.editor.setFocus() - self.search_text.add_current_text() - return state - - @Slot() - def find_previous(self, set_focus=True): - """Find previous occurrence""" - state = self.find(changed=False, forward=False, rehighlight=False, - multiline_replace_check=False) - if set_focus: - self.editor.setFocus() - return state - - def text_has_been_edited(self, text): - """Find text has been edited (this slot won't be triggered when - setting the search pattern combo box text programmatically)""" - self.find(changed=True, forward=True, start_highlight_timer=True) - - def highlight_matches(self): - """Highlight found results""" - if self.is_code_editor: - text = self.search_text.currentText() - case = self.case_button.isChecked() - word = self.words_button.isChecked() - regexp = self.re_button.isChecked() - self.editor.highlight_found_results(text, word=word, - regexp=regexp, case=case) - - def clear_matches(self): - """Clear all highlighted matches""" - if self.is_code_editor: - self.editor.clear_found_results() - - def find(self, changed=True, forward=True, rehighlight=True, - start_highlight_timer=False, multiline_replace_check=True): - """Call the find function""" - # When several lines are selected in the editor and replace box is - # activated, dynamic search is deactivated to prevent changing the - # selection. Otherwise we show matching items. - if multiline_replace_check and self.replace_widgets[0].isVisible(): - sel_text = self.editor.get_selected_text() - if len(to_text_string(sel_text).splitlines()) > 1: - return None - text = self.search_text.currentText() - if len(text) == 0: - self.search_text.lineEdit().setStyleSheet("") - if not self.is_code_editor: - # Clears the selection for WebEngine - self.editor.find_text('') - self.change_number_matches() - self.clear_matches() - return None - else: - case = self.case_button.isChecked() - word = self.words_button.isChecked() - regexp = self.re_button.isChecked() - found = self.editor.find_text(text, changed, forward, case=case, - word=word, regexp=regexp) - - stylesheet = self.STYLE[found] - tooltip = self.TOOLTIP[found] - if not found and regexp: - error_msg = regexp_error_msg(text) - if error_msg: # special styling for regexp errors - stylesheet = self.STYLE['regexp_error'] - tooltip = self.TOOLTIP['regexp_error'] + ': ' + error_msg - self.search_text.lineEdit().setStyleSheet(stylesheet) - self.search_text.setToolTip(tooltip) - - if self.is_code_editor and found: - cursor = QTextCursor(self.editor.textCursor()) - TextHelper(self.editor).unfold_if_colapsed(cursor) - - if rehighlight or not self.editor.found_results: - self.highlight_timer.stop() - if start_highlight_timer: - self.highlight_timer.start() - else: - self.highlight_matches() - else: - self.clear_matches() - - number_matches = self.editor.get_number_matches(text, case=case, - regexp=regexp, - word=word) - if hasattr(self.editor, 'get_match_number'): - match_number = self.editor.get_match_number(text, case=case, - regexp=regexp, - word=word) - else: - match_number = 0 - self.change_number_matches(current_match=match_number, - total_matches=number_matches) - return found - - @Slot() - def replace_find(self, focus_replace_text=False): - """Replace and find.""" - if self.editor is None: - return - replace_text = to_text_string(self.replace_text.currentText()) - search_text = to_text_string(self.search_text.currentText()) - re_pattern = None - case = self.case_button.isChecked() - re_flags = re.MULTILINE if case else re.IGNORECASE | re.MULTILINE - - # Check regexp before proceeding - if self.re_button.isChecked(): - try: - re_pattern = re.compile(search_text, flags=re_flags) - # Check if replace_text can be substituted in re_pattern - # Fixes spyder-ide/spyder#7177. - re_pattern.sub(replace_text, '') - except re.error: - # Do nothing with an invalid regexp - return - - # First found - seltxt = to_text_string(self.editor.get_selected_text()) - cmptxt1 = search_text if case else search_text.lower() - cmptxt2 = seltxt if case else seltxt.lower() - do_replace = True - if re_pattern is None: - has_selected = self.editor.has_selected_text() - if not has_selected or cmptxt1 != cmptxt2: - if not self.find(changed=False, forward=True, - rehighlight=False): - do_replace = False - else: - if len(re_pattern.findall(cmptxt2)) <= 0: - if not self.find(changed=False, forward=True, - rehighlight=False): - do_replace = False - cursor = None - if do_replace: - cursor = self.editor.textCursor() - cursor.beginEditBlock() - - if re_pattern is None: - cursor.removeSelectedText() - cursor.insertText(replace_text) - else: - seltxt = to_text_string(cursor.selectedText()) - - # Note: If the selection obtained from an editor spans a line - # break, the text will contain a Unicode U+2029 paragraph - # separator character instead of a newline \n character. - # See: spyder-ide/spyder#2675 - eol_char = get_eol_chars(self.editor.toPlainText()) - seltxt = seltxt.replace(u'\u2029', eol_char) - - cursor.removeSelectedText() - cursor.insertText(re_pattern.sub(replace_text, seltxt)) - - if self.find_next(set_focus=False): - found_cursor = self.editor.textCursor() - cursor.setPosition(found_cursor.selectionStart(), - QTextCursor.MoveAnchor) - cursor.setPosition(found_cursor.selectionEnd(), - QTextCursor.KeepAnchor) - - - if cursor is not None: - cursor.endEditBlock() - - if focus_replace_text: - self.replace_text.setFocus() - else: - self.editor.setFocus() - - if getattr(self.editor, 'document_did_change', False): - self.editor.document_did_change() - - @Slot() - def replace_find_all(self): - """Replace and find all matching occurrences""" - if self.editor is None: - return - replace_text = to_text_string(self.replace_text.currentText()) - search_text = to_text_string(self.search_text.currentText()) - re_pattern = None - case = self.case_button.isChecked() - re_flags = re.MULTILINE if case else re.IGNORECASE | re.MULTILINE - re_enabled = self.re_button.isChecked() - # Check regexp before proceeding - if re_enabled: - try: - re_pattern = re.compile(search_text, flags=re_flags) - # Check if replace_text can be substituted in re_pattern - # Fixes spyder-ide/spyder#7177. - re_pattern.sub(replace_text, '') - except re.error: - # Do nothing with an invalid regexp - return - else: - re_pattern = re.compile(re.escape(search_text), flags=re_flags) - - cursor = self.editor._select_text("sof", "eof") - text = self.editor.toPlainText() - cursor.beginEditBlock() - cursor.removeSelectedText() - cursor.insertText(re_pattern.sub(replace_text, text)) - cursor.endEditBlock() - - self.editor.setFocus() - - @Slot() - def replace_find_selection(self, focus_replace_text=False): - """Replace and find in the current selection""" - if self.editor is not None: - replace_text = to_text_string(self.replace_text.currentText()) - search_text = to_text_string(self.search_text.currentText()) - case = self.case_button.isChecked() - word = self.words_button.isChecked() - re_flags = re.MULTILINE if case else re.IGNORECASE | re.MULTILINE - - re_pattern = None - if self.re_button.isChecked(): - pattern = search_text - else: - pattern = re.escape(search_text) - replace_text = replace_text.replace('\\', r'\\') - if word: # match whole words only - pattern = r'\b{pattern}\b'.format(pattern=pattern) - - # Check regexp before proceeding - try: - re_pattern = re.compile(pattern, flags=re_flags) - # Check if replace_text can be substituted in re_pattern - # Fixes spyder-ide/spyder#7177. - re_pattern.sub(replace_text, '') - except re.error as e: - # Do nothing with an invalid regexp - return - - selected_text = to_text_string(self.editor.get_selected_text()) - replacement = re_pattern.sub(replace_text, selected_text) - if replacement != selected_text: - cursor = self.editor.textCursor() - start_pos = cursor.selectionStart() - cursor.beginEditBlock() - cursor.removeSelectedText() - cursor.insertText(replacement) - # Restore selection - self.editor.set_cursor_position(start_pos) - for c in range(len(replacement)): - self.editor.extend_selection_to_next('character', 'right') - cursor.endEditBlock() - - if focus_replace_text: - self.replace_text.setFocus() - else: - self.editor.setFocus() - - if getattr(self.editor, 'document_did_change', False): - self.editor.document_did_change() - - def change_number_matches(self, current_match=0, total_matches=0): - """Change number of match and total matches.""" - if current_match and total_matches: - matches_string = u"{} {} {}".format(current_match, _(u"of"), - total_matches) - self.number_matches_text.setText(matches_string) - elif total_matches: - matches_string = u"{} {}".format(total_matches, _(u"matches")) - self.number_matches_text.setText(matches_string) - else: - self.number_matches_text.setText(_(u"no matches")) +# -*- coding: utf-8 -*- +# +# Copyright © Spyder Project Contributors +# Licensed under the terms of the MIT License +# (see spyder/__init__.py for details) + +"""Find/Replace widget""" + +# pylint: disable=C0103 +# pylint: disable=R0903 +# pylint: disable=R0911 +# pylint: disable=R0201 + +# Standard library imports +import re + +# Third party imports +from qtpy.QtCore import Qt, QTimer, Signal, Slot, QEvent +from qtpy.QtGui import QTextCursor +from qtpy.QtWidgets import (QGridLayout, QHBoxLayout, QLabel, + QSizePolicy, QWidget) + +# Local imports +from spyder.config.base import _ +from spyder.config.manager import CONF +from spyder.py3compat import to_text_string +from spyder.utils.icon_manager import ima +from spyder.utils.misc import regexp_error_msg +from spyder.plugins.editor.utils.editor import TextHelper +from spyder.utils.qthelpers import create_toolbutton +from spyder.utils.sourcecode import get_eol_chars +from spyder.widgets.comboboxes import PatternComboBox + + +def is_position_sup(pos1, pos2): + """Return True is pos1 > pos2""" + return pos1 > pos2 + +def is_position_inf(pos1, pos2): + """Return True is pos1 < pos2""" + return pos1 < pos2 + + +class FindReplace(QWidget): + """Find widget""" + STYLE = {False: "background-color:'#F37E12';", + True: "", + None: "", + 'regexp_error': "background-color:'#E74C3C';", + } + TOOLTIP = {False: _("No matches"), + True: _("Search string"), + None: _("Search string"), + 'regexp_error': _("Regular expression error") + } + visibility_changed = Signal(bool) + return_shift_pressed = Signal() + return_pressed = Signal() + + def __init__(self, parent, enable_replace=False): + QWidget.__init__(self, parent) + self.enable_replace = enable_replace + self.editor = None + self.is_code_editor = None + self.setStyleSheet( + "QComboBox {" + "padding-right: 0px;" + "padding-left: 0px;" + "}") + + glayout = QGridLayout() + glayout.setContentsMargins(0, 0, 0, 0) + self.setLayout(glayout) + + self.close_button = create_toolbutton(self, triggered=self.hide, + icon=ima.icon('DialogCloseButton')) + glayout.addWidget(self.close_button, 0, 0) + + # Find layout + self.search_text = PatternComboBox(self, tip=_("Search string"), + adjust_to_minimum=False) + + self.return_shift_pressed.connect( + lambda: + self.find(changed=False, forward=False, rehighlight=False, + multiline_replace_check = False)) + + self.return_pressed.connect( + lambda: + self.find(changed=False, forward=True, rehighlight=False, + multiline_replace_check = False)) + + self.search_text.lineEdit().textEdited.connect( + self.text_has_been_edited) + + self.number_matches_text = QLabel(self) + self.replace_on = False + self.replace_text_button = create_toolbutton( + self, + toggled=self.change_replace_state, + icon=ima.icon('replace'), + tip=_("Replace text") + ) + self.previous_button = create_toolbutton(self, + triggered=self.find_previous, + icon=ima.icon('findprevious'), + tip=_("Find previous")) + self.next_button = create_toolbutton(self, + triggered=self.find_next, + icon=ima.icon('findnext'), + tip=_("Find next")) + self.next_button.clicked.connect(self.update_search_combo) + self.previous_button.clicked.connect(self.update_search_combo) + + self.re_button = create_toolbutton(self, icon=ima.icon('regex'), + tip=_("Regular expression")) + self.re_button.setCheckable(True) + self.re_button.toggled.connect(lambda state: self.find()) + + self.case_button = create_toolbutton(self, + icon=ima.icon( + "format_letter_case"), + tip=_("Case Sensitive")) + self.case_button.setCheckable(True) + self.case_button.toggled.connect(lambda state: self.find()) + + self.words_button = create_toolbutton(self, + icon=ima.icon("whole_words"), + tip=_("Whole words")) + self.words_button.setCheckable(True) + self.words_button.toggled.connect(lambda state: self.find()) + + hlayout = QHBoxLayout() + self.widgets = [self.close_button, self.search_text, + self.number_matches_text, self.replace_text_button, + self.previous_button, self.next_button, + self.re_button, self.case_button, + self.words_button] + for widget in self.widgets[1:]: + hlayout.addWidget(widget) + glayout.addLayout(hlayout, 0, 1) + + # Replace layout + replace_with = QLabel(_("Replace with:")) + self.replace_text = PatternComboBox(self, adjust_to_minimum=False, + tip=_('Replace string')) + self.replace_text.valid.connect( + lambda _: self.replace_find(focus_replace_text=True)) + self.replace_button = create_toolbutton(self, + text=_('Find next'), + icon=ima.icon('DialogApplyButton'), + triggered=self.replace_find, + text_beside_icon=True) + self.replace_sel_button = create_toolbutton(self, + text=_('In selection'), + icon=ima.icon('DialogApplyButton'), + triggered=self.replace_find_selection, + text_beside_icon=True) + self.replace_sel_button.clicked.connect(self.update_replace_combo) + self.replace_sel_button.clicked.connect(self.update_search_combo) + + self.replace_all_button = create_toolbutton(self, + text=_('All'), + icon=ima.icon('DialogApplyButton'), + triggered=self.replace_find_all, + text_beside_icon=True) + self.replace_all_button.clicked.connect(self.update_replace_combo) + self.replace_all_button.clicked.connect(self.update_search_combo) + + self.replace_layout = QHBoxLayout() + widgets = [replace_with, self.replace_text, self.replace_button, + self.replace_sel_button, self.replace_all_button] + for widget in widgets: + self.replace_layout.addWidget(widget) + glayout.addLayout(self.replace_layout, 1, 1) + self.widgets.extend(widgets) + self.replace_widgets = widgets + self.hide_replace() + + self.search_text.setTabOrder(self.search_text, self.replace_text) + + self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) + + self.shortcuts = self.create_shortcuts(parent) + + self.highlight_timer = QTimer(self) + self.highlight_timer.setSingleShot(True) + self.highlight_timer.setInterval(300) + self.highlight_timer.timeout.connect(self.highlight_matches) + self.search_text.installEventFilter(self) + + def eventFilter(self, widget, event): + """Event filter for search_text widget. + + Emits signals when presing Enter and Shift+Enter. + This signals are used for search forward and backward. + Also, a crude hack to get tab working in the Find/Replace boxes. + """ + + # Type check: Prevent error in PySide where 'event' may be of type + # QtGui.QPainter (for whatever reason). + if not isinstance(event, QEvent): + return True + + if event.type() == QEvent.KeyPress: + key = event.key() + shift = event.modifiers() & Qt.ShiftModifier + + if key == Qt.Key_Return: + if shift: + self.return_shift_pressed.emit() + else: + self.return_pressed.emit() + + if key == Qt.Key_Tab: + if self.search_text.hasFocus(): + self.replace_text.set_current_text( + self.search_text.currentText()) + self.focusNextChild() + + return super(FindReplace, self).eventFilter(widget, event) + + def create_shortcuts(self, parent): + """Create shortcuts for this widget""" + # Configurable + findnext = CONF.config_shortcut( + self.find_next, + context='find_replace', + name='Find next', + parent=parent) + + findprev = CONF.config_shortcut( + self.find_previous, + context='find_replace', + name='Find previous', + parent=parent) + + togglefind = CONF.config_shortcut( + self.show, + context='find_replace', + name='Find text', + parent=parent) + + togglereplace = CONF.config_shortcut( + self.show_replace, + context='find_replace', + name='Replace text', + parent=parent) + + hide = CONF.config_shortcut( + self.hide, + context='find_replace', + name='hide find and replace', + parent=self) + + return [findnext, findprev, togglefind, togglereplace, hide] + + def get_shortcut_data(self): + """ + Returns shortcut data, a list of tuples (shortcut, text, default) + shortcut (QShortcut or QAction instance) + text (string): action/shortcut description + default (string): default key sequence + """ + return [sc.data for sc in self.shortcuts] + + def update_search_combo(self): + self.search_text.lineEdit().returnPressed.emit() + + def update_replace_combo(self): + self.replace_text.lineEdit().returnPressed.emit() + + @Slot(bool) + def toggle_highlighting(self, state): + """Toggle the 'highlight all results' feature""" + if self.editor is not None: + if state: + self.highlight_matches() + else: + self.clear_matches() + + def show(self, hide_replace=True): + """Overrides Qt Method""" + QWidget.show(self) + self.visibility_changed.emit(True) + self.change_number_matches() + if self.editor is not None: + if hide_replace: + if self.replace_widgets[0].isVisible(): + self.hide_replace() + text = self.editor.get_selected_text() + # When selecting several lines, and replace box is activated the + # text won't be replaced for the selection + if hide_replace or len(text.splitlines()) <= 1: + highlighted = True + # If no text is highlighted for search, use whatever word is + # under the cursor + if not text: + highlighted = False + try: + cursor = self.editor.textCursor() + cursor.select(QTextCursor.WordUnderCursor) + text = to_text_string(cursor.selectedText()) + except AttributeError: + # We can't do this for all widgets, e.g. WebView's + pass + + # Now that text value is sorted out, use it for the search + if text and not self.search_text.currentText() or highlighted: + self.search_text.setEditText(text) + self.search_text.lineEdit().selectAll() + self.refresh() + else: + self.search_text.lineEdit().selectAll() + self.search_text.setFocus() + + @Slot() + def replace_widget(self, replace_on): + """Show and hide replace widget""" + if replace_on: + self.show_replace() + else: + self.hide_replace() + + def change_replace_state(self): + """Handle the change of the replace state widget.""" + self.replace_on = not self.replace_on + self.replace_text_button.setChecked(self.replace_on) + self.replace_widget(self.replace_on) + + def hide(self): + """Overrides Qt Method""" + for widget in self.replace_widgets: + widget.hide() + QWidget.hide(self) + self.visibility_changed.emit(False) + if self.editor is not None: + self.editor.setFocus() + self.clear_matches() + + def show_replace(self): + """Show replace widgets""" + if self.enable_replace: + self.show(hide_replace=False) + for widget in self.replace_widgets: + widget.show() + + def hide_replace(self): + """Hide replace widgets""" + for widget in self.replace_widgets: + widget.hide() + + def refresh(self): + """Refresh widget""" + if self.isHidden(): + if self.editor is not None: + self.clear_matches() + return + state = self.editor is not None + for widget in self.widgets: + widget.setEnabled(state) + if state: + self.find() + + def set_editor(self, editor, refresh=True): + """ + Set associated editor/web page: + codeeditor.base.TextEditBaseWidget + browser.WebView + """ + self.editor = editor + # Note: This is necessary to test widgets/editor.py + # in Qt builds that don't have web widgets + try: + from qtpy.QtWebEngineWidgets import QWebEngineView + except ImportError: + QWebEngineView = type(None) + self.words_button.setVisible(not isinstance(editor, QWebEngineView)) + self.re_button.setVisible(not isinstance(editor, QWebEngineView)) + from spyder.plugins.editor.widgets.codeeditor import CodeEditor + self.is_code_editor = isinstance(editor, CodeEditor) + if refresh: + self.refresh() + if self.isHidden() and editor is not None: + self.clear_matches() + + @Slot() + def find_next(self, set_focus=True): + """Find next occurrence""" + state = self.find(changed=False, forward=True, rehighlight=False, + multiline_replace_check=False) + if set_focus: + self.editor.setFocus() + self.search_text.add_current_text() + return state + + @Slot() + def find_previous(self, set_focus=True): + """Find previous occurrence""" + state = self.find(changed=False, forward=False, rehighlight=False, + multiline_replace_check=False) + if set_focus: + self.editor.setFocus() + return state + + def text_has_been_edited(self, text): + """Find text has been edited (this slot won't be triggered when + setting the search pattern combo box text programmatically)""" + self.find(changed=True, forward=True, start_highlight_timer=True) + + def highlight_matches(self): + """Highlight found results""" + if self.is_code_editor: + text = self.search_text.currentText() + case = self.case_button.isChecked() + word = self.words_button.isChecked() + regexp = self.re_button.isChecked() + self.editor.highlight_found_results(text, word=word, + regexp=regexp, case=case) + + def clear_matches(self): + """Clear all highlighted matches""" + if self.is_code_editor: + self.editor.clear_found_results() + + def find(self, changed=True, forward=True, rehighlight=True, + start_highlight_timer=False, multiline_replace_check=True): + """Call the find function""" + # When several lines are selected in the editor and replace box is + # activated, dynamic search is deactivated to prevent changing the + # selection. Otherwise we show matching items. + if multiline_replace_check and self.replace_widgets[0].isVisible(): + sel_text = self.editor.get_selected_text() + if len(to_text_string(sel_text).splitlines()) > 1: + return None + text = self.search_text.currentText() + if len(text) == 0: + self.search_text.lineEdit().setStyleSheet("") + if not self.is_code_editor: + # Clears the selection for WebEngine + self.editor.find_text('') + self.change_number_matches() + self.clear_matches() + return None + else: + case = self.case_button.isChecked() + word = self.words_button.isChecked() + regexp = self.re_button.isChecked() + found = self.editor.find_text(text, changed, forward, case=case, + word=word, regexp=regexp) + + stylesheet = self.STYLE[found] + tooltip = self.TOOLTIP[found] + if not found and regexp: + error_msg = regexp_error_msg(text) + if error_msg: # special styling for regexp errors + stylesheet = self.STYLE['regexp_error'] + tooltip = self.TOOLTIP['regexp_error'] + ': ' + error_msg + self.search_text.lineEdit().setStyleSheet(stylesheet) + self.search_text.setToolTip(tooltip) + + if self.is_code_editor and found: + cursor = QTextCursor(self.editor.textCursor()) + TextHelper(self.editor).unfold_if_colapsed(cursor) + + if rehighlight or not self.editor.found_results: + self.highlight_timer.stop() + if start_highlight_timer: + self.highlight_timer.start() + else: + self.highlight_matches() + else: + self.clear_matches() + + number_matches = self.editor.get_number_matches(text, case=case, + regexp=regexp, + word=word) + if hasattr(self.editor, 'get_match_number'): + match_number = self.editor.get_match_number(text, case=case, + regexp=regexp, + word=word) + else: + match_number = 0 + self.change_number_matches(current_match=match_number, + total_matches=number_matches) + return found + + @Slot() + def replace_find(self, focus_replace_text=False): + """Replace and find.""" + if self.editor is None: + return + replace_text = to_text_string(self.replace_text.currentText()) + search_text = to_text_string(self.search_text.currentText()) + re_pattern = None + case = self.case_button.isChecked() + re_flags = re.MULTILINE if case else re.IGNORECASE | re.MULTILINE + + # Check regexp before proceeding + if self.re_button.isChecked(): + try: + re_pattern = re.compile(search_text, flags=re_flags) + # Check if replace_text can be substituted in re_pattern + # Fixes spyder-ide/spyder#7177. + re_pattern.sub(replace_text, '') + except re.error: + # Do nothing with an invalid regexp + return + + # First found + seltxt = to_text_string(self.editor.get_selected_text()) + cmptxt1 = search_text if case else search_text.lower() + cmptxt2 = seltxt if case else seltxt.lower() + do_replace = True + if re_pattern is None: + has_selected = self.editor.has_selected_text() + if not has_selected or cmptxt1 != cmptxt2: + if not self.find(changed=False, forward=True, + rehighlight=False): + do_replace = False + else: + if len(re_pattern.findall(cmptxt2)) <= 0: + if not self.find(changed=False, forward=True, + rehighlight=False): + do_replace = False + cursor = None + if do_replace: + cursor = self.editor.textCursor() + cursor.beginEditBlock() + + if re_pattern is None: + cursor.removeSelectedText() + cursor.insertText(replace_text) + else: + seltxt = to_text_string(cursor.selectedText()) + + # Note: If the selection obtained from an editor spans a line + # break, the text will contain a Unicode U+2029 paragraph + # separator character instead of a newline \n character. + # See: spyder-ide/spyder#2675 + eol_char = get_eol_chars(self.editor.toPlainText()) + seltxt = seltxt.replace(u'\u2029', eol_char) + + cursor.removeSelectedText() + cursor.insertText(re_pattern.sub(replace_text, seltxt)) + + if self.find_next(set_focus=False): + found_cursor = self.editor.textCursor() + cursor.setPosition(found_cursor.selectionStart(), + QTextCursor.MoveAnchor) + cursor.setPosition(found_cursor.selectionEnd(), + QTextCursor.KeepAnchor) + + + if cursor is not None: + cursor.endEditBlock() + + if focus_replace_text: + self.replace_text.setFocus() + else: + self.editor.setFocus() + + if getattr(self.editor, 'document_did_change', False): + self.editor.document_did_change() + + @Slot() + def replace_find_all(self): + """Replace and find all matching occurrences""" + if self.editor is None: + return + replace_text = to_text_string(self.replace_text.currentText()) + search_text = to_text_string(self.search_text.currentText()) + re_pattern = None + case = self.case_button.isChecked() + re_flags = re.MULTILINE if case else re.IGNORECASE | re.MULTILINE + re_enabled = self.re_button.isChecked() + # Check regexp before proceeding + if re_enabled: + try: + re_pattern = re.compile(search_text, flags=re_flags) + # Check if replace_text can be substituted in re_pattern + # Fixes spyder-ide/spyder#7177. + re_pattern.sub(replace_text, '') + except re.error: + # Do nothing with an invalid regexp + return + else: + re_pattern = re.compile(re.escape(search_text), flags=re_flags) + + cursor = self.editor._select_text("sof", "eof") + text = self.editor.toPlainText() + cursor.beginEditBlock() + cursor.removeSelectedText() + cursor.insertText(re_pattern.sub(replace_text, text)) + cursor.endEditBlock() + + self.editor.setFocus() + + @Slot() + def replace_find_selection(self, focus_replace_text=False): + """Replace and find in the current selection""" + if self.editor is not None: + replace_text = to_text_string(self.replace_text.currentText()) + search_text = to_text_string(self.search_text.currentText()) + case = self.case_button.isChecked() + word = self.words_button.isChecked() + re_flags = re.MULTILINE if case else re.IGNORECASE | re.MULTILINE + + re_pattern = None + if self.re_button.isChecked(): + pattern = search_text + else: + pattern = re.escape(search_text) + replace_text = replace_text.replace('\\', r'\\') + if word: # match whole words only + pattern = r'\b{pattern}\b'.format(pattern=pattern) + + # Check regexp before proceeding + try: + re_pattern = re.compile(pattern, flags=re_flags) + # Check if replace_text can be substituted in re_pattern + # Fixes spyder-ide/spyder#7177. + re_pattern.sub(replace_text, '') + except re.error as e: + # Do nothing with an invalid regexp + return + + selected_text = to_text_string(self.editor.get_selected_text()) + replacement = re_pattern.sub(replace_text, selected_text) + if replacement != selected_text: + cursor = self.editor.textCursor() + start_pos = cursor.selectionStart() + cursor.beginEditBlock() + cursor.removeSelectedText() + cursor.insertText(replacement) + # Restore selection + self.editor.set_cursor_position(start_pos) + for c in range(len(replacement)): + self.editor.extend_selection_to_next('character', 'right') + cursor.endEditBlock() + + if focus_replace_text: + self.replace_text.setFocus() + else: + self.editor.setFocus() + + if getattr(self.editor, 'document_did_change', False): + self.editor.document_did_change() + + def change_number_matches(self, current_match=0, total_matches=0): + """Change number of match and total matches.""" + if current_match and total_matches: + matches_string = u"{} {} {}".format(current_match, _(u"of"), + total_matches) + self.number_matches_text.setText(matches_string) + elif total_matches: + matches_string = u"{} {}".format(total_matches, _(u"matches")) + self.number_matches_text.setText(matches_string) + else: + self.number_matches_text.setText(_(u"no matches")) diff --git a/spyder/widgets/mixins.py b/spyder/widgets/mixins.py index d1013e2a4d5..783c71638d4 100644 --- a/spyder/widgets/mixins.py +++ b/spyder/widgets/mixins.py @@ -1,1646 +1,1646 @@ -# -*- coding: utf-8 -*- -# -# Copyright © Spyder Project Contributors -# Licensed under the terms of the MIT License -# (see spyder/__init__.py for details) - -"""Mix-in classes - -These classes were created to be able to provide Spyder's regular text and -console widget features to an independent widget based on QTextEdit for the -IPython console plugin. -""" - -# Standard library imports -from __future__ import print_function -import os -import os.path as osp -import re -import sre_constants -import sys -import textwrap -from pkg_resources import parse_version - -# Third party imports -from qtpy import QT_VERSION -from qtpy.QtCore import QPoint, QRegularExpression, Qt -from qtpy.QtGui import QCursor, QTextCursor, QTextDocument -from qtpy.QtWidgets import QApplication -from spyder_kernels.utils.dochelpers import (getargspecfromtext, getobj, - getsignaturefromtext) - -# Local imports -from spyder.config.manager import CONF -from spyder.py3compat import to_text_string -from spyder.utils import encoding, sourcecode -from spyder.utils import syntaxhighlighters as sh -from spyder.utils.misc import get_error_match -from spyder.utils.palette import QStylePalette -from spyder.widgets.arraybuilder import ArrayBuilderDialog - - -# List of possible EOL symbols -EOL_SYMBOLS = [ - # Put first as it correspond to a single line return - "\r\n", # Carriage Return + Line Feed - "\r", # Carriage Return - "\n", # Line Feed - "\v", # Line Tabulation - "\x0b", # Line Tabulation - "\f", # Form Feed - "\x0c", # Form Feed - "\x1c", # File Separator - "\x1d", # Group Separator - "\x1e", # Record Separator - "\x85", # Next Line (C1 Control Code) - "\u2028", # Line Separator - "\u2029", # Paragraph Separator -] - - -class BaseEditMixin(object): - - _PARAMETER_HIGHLIGHT_COLOR = QStylePalette.COLOR_ACCENT_4 - _DEFAULT_TITLE_COLOR = QStylePalette.COLOR_ACCENT_4 - _CHAR_HIGHLIGHT_COLOR = QStylePalette.COLOR_ACCENT_4 - _DEFAULT_TEXT_COLOR = QStylePalette.COLOR_TEXT_2 - _DEFAULT_LANGUAGE = 'python' - _DEFAULT_MAX_LINES = 10 - _DEFAULT_MAX_WIDTH = 60 - _DEFAULT_COMPLETION_HINT_MAX_WIDTH = 52 - _DEFAULT_MAX_HINT_LINES = 20 - _DEFAULT_MAX_HINT_WIDTH = 85 - - # The following signals are used to indicate text changes on the editor. - sig_will_insert_text = None - sig_will_remove_selection = None - sig_text_was_inserted = None - - _styled_widgets = set() - - def __init__(self): - self.eol_chars = None - self.calltip_size = 600 - - #------Line number area - def get_linenumberarea_width(self): - """Return line number area width""" - # Implemented in CodeEditor, but needed for calltip/completion widgets - return 0 - - def calculate_real_position(self, point): - """ - Add offset to a point, to take into account the Editor panels. - - This is reimplemented in CodeEditor, in other widgets it returns - the same point. - """ - return point - - # --- Tooltips and Calltips - def _calculate_position(self, at_line=None, at_point=None): - """ - Calculate a global point position `QPoint(x, y)`, for a given - line, local cursor position, or local point. - """ - font = self.font() - - if at_point is not None: - # Showing tooltip at point position - margin = (self.document().documentMargin() / 2) + 1 - cx = int(at_point.x() - margin) - cy = int(at_point.y() - margin) - elif at_line is not None: - # Showing tooltip at line - cx = 5 - line = at_line - 1 - cursor = QTextCursor(self.document().findBlockByNumber(line)) - cy = int(self.cursorRect(cursor).top()) - else: - # Showing tooltip at cursor position - cx, cy = self.get_coordinates('cursor') - cx = int(cx) - cy = int(cy - font.pointSize() / 2) - - # Calculate vertical delta - # The needed delta changes with font size, so we use a power law - if sys.platform == 'darwin': - delta = int((font.pointSize() * 1.20) ** 0.98 + 4.5) - elif os.name == 'nt': - delta = int((font.pointSize() * 1.20) ** 1.05) + 7 - else: - delta = int((font.pointSize() * 1.20) ** 0.98) + 7 - # delta = font.pointSize() + 5 - - # Map to global coordinates - point = self.mapToGlobal(QPoint(cx, cy)) - point = self.calculate_real_position(point) - point.setY(point.y() + delta) - - return point - - def _update_stylesheet(self, widget): - """Update the background stylesheet to make it lighter.""" - # Update the stylesheet for a given widget at most once - # because Qt is slow to repeatedly parse & apply CSS - if id(widget) in self._styled_widgets: - return - self._styled_widgets.add(id(widget)) - background = QStylePalette.COLOR_BACKGROUND_4 - border = QStylePalette.COLOR_TEXT_4 - name = widget.__class__.__name__ - widget.setObjectName(name) - css = ''' - {0}#{0} {{ - background-color:{1}; - border: 1px solid {2}; - }}'''.format(name, background, border) - widget.setStyleSheet(css) - - def _get_inspect_shortcut(self): - """ - Queries the editor's config to get the current "Inspect" shortcut. - """ - value = CONF.get('shortcuts', 'editor/inspect current object') - if value: - if sys.platform == "darwin": - value = value.replace('Ctrl', 'Cmd') - return value - - def _format_text(self, title=None, signature=None, text=None, - inspect_word=None, title_color=None, max_lines=None, - max_width=_DEFAULT_MAX_WIDTH, display_link=False, - text_new_line=False, with_html_format=False): - """ - Create HTML template for calltips and tooltips. - - This will display title and text as separate sections and add `...` - - ---------------------------------------- - | `title` (with `title_color`) | - ---------------------------------------- - | `signature` | - | | - | `text` (ellided to `max_lines`) | - | | - ---------------------------------------- - | Link or shortcut with `inspect_word` | - ---------------------------------------- - """ - BASE_TEMPLATE = u''' -
    - {main_text} -
    - ''' - # Get current font properties - font = self.font() - font_family = font.family() - title_size = font.pointSize() - text_size = title_size - 1 if title_size > 9 else title_size - text_color = self._DEFAULT_TEXT_COLOR - - template = '' - if title: - template += BASE_TEMPLATE.format( - font_family=font_family, - size=title_size, - color=title_color, - main_text=title, - ) - - if text or signature: - template += '
    ' - - if signature: - signature = signature.strip('\r\n') - template += BASE_TEMPLATE.format( - font_family=font_family, - size=text_size, - color=text_color, - main_text=signature, - ) - - # Documentation/text handling - if (text is None or not text.strip() or - text.strip() == ''): - text = 'No documentation available' - else: - text = text.strip() - - if not with_html_format: - # All these replacements are need to properly divide the - # text in actual paragraphs and wrap the text on each one - paragraphs = (text - .replace(u"\xa0", u" ") - .replace("\n\n", "") - .replace(".\n", ".") - .replace("\n-", "-") - .replace("-\n", "-") - .replace("\n=", "=") - .replace("=\n", "=") - .replace("\n*", "*") - .replace("*\n", "*") - .replace("\n ", " ") - .replace(" \n", " ") - .replace("\n", " ") - .replace("", "\n\n") - .replace("", "\n").splitlines()) - new_paragraphs = [] - for paragraph in paragraphs: - # Wrap text - new_paragraph = textwrap.wrap(paragraph, width=max_width) - - # Remove empty lines at the beginning - new_paragraph = [l for l in new_paragraph if l.strip()] - - # Merge paragraph text - new_paragraph = '\n'.join(new_paragraph) - - # Add new paragraph - new_paragraphs.append(new_paragraph) - - # Join paragraphs and split in lines for max_lines check - paragraphs = '\n'.join(new_paragraphs) - paragraphs = paragraphs.strip('\r\n') - lines = paragraphs.splitlines() - - # Check that the first line is not empty - if len(lines) > 0 and not lines[0].strip(): - lines = lines[1:] - else: - lines = [l for l in text.split('\n') if l.strip()] - - # Limit max number of text displayed - if max_lines: - if len(lines) > max_lines: - text = '\n'.join(lines[:max_lines]) + ' ...' - else: - text = '\n'.join(lines) - - text = text.replace('\n', '
    ') - if text_new_line and signature: - text = '
    ' + text - - template += BASE_TEMPLATE.format( - font_family=font_family, - size=text_size, - color=text_color, - main_text=text, - ) - - help_text = '' - if inspect_word: - if display_link: - help_text = ( - '' - 'Click anywhere in this tooltip for additional help' - ''.format( - font_size=text_size, - font_family=font_family, - ) - ) - else: - shortcut = self._get_inspect_shortcut() - if shortcut: - base_style = ( - f'background-color:{QStylePalette.COLOR_BACKGROUND_4};' - f'color:{QStylePalette.COLOR_TEXT_1};' - 'font-size:11px;' - ) - help_text = '' - # ( - # 'Press ' - # '[' - # '' - # '{0}] for aditional ' - # 'help'.format(shortcut, base_style) - # ) - - if help_text and inspect_word: - if display_link: - template += ( - '
    ' - '
    ' - f'' - ''.format(font_family=font_family, - size=text_size) - ) + help_text + '
    ' - else: - template += ( - '
    ' - '
    ' - '' - '' + help_text + '
    ' - ) - - return template - - def _format_signature(self, signatures, parameter=None, - max_width=_DEFAULT_MAX_WIDTH, - parameter_color=_PARAMETER_HIGHLIGHT_COLOR, - char_color=_CHAR_HIGHLIGHT_COLOR, - language=_DEFAULT_LANGUAGE): - """ - Create HTML template for signature. - - This template will include indent after the method name, a highlight - color for the active parameter and highlights for special chars. - - Special chars depend on the language. - """ - language = getattr(self, 'language', language).lower() - active_parameter_template = ( - '' - '{parameter}' - '' - ) - chars_template = ( - '{char}' - '' - ) - - def handle_sub(matchobj): - """ - Handle substitution of active parameter template. - - This ensures the correct highlight of the active parameter. - """ - match = matchobj.group(0) - new = match.replace(parameter, active_parameter_template) - return new - - if not isinstance(signatures, list): - signatures = [signatures] - - new_signatures = [] - for signature in signatures: - # Remove duplicate spaces - signature = ' '.join(signature.split()) - - # Replace initial spaces - signature = signature.replace('( ', '(') - - # Process signature template - if parameter and language == 'python': - # Escape all possible regex characters - # ( ) { } | [ ] . ^ $ * + - escape_regex_chars = ['|', '.', '^', '$', '*', '+'] - remove_regex_chars = ['(', ')', '{', '}', '[', ']'] - regex_parameter = parameter - for regex_char in escape_regex_chars + remove_regex_chars: - if regex_char in escape_regex_chars: - escape_char = r'\{char}'.format(char=regex_char) - regex_parameter = regex_parameter.replace(regex_char, - escape_char) - else: - regex_parameter = regex_parameter.replace(regex_char, - '') - parameter = parameter.replace(regex_char, '') - - pattern = (r'[\*|\(|\[|\s](' + regex_parameter + - r')[,|\)|\]|\s|=]') - - formatted_lines = [] - name = signature.split('(')[0] - indent = ' ' * (len(name) + 1) - rows = textwrap.wrap(signature, width=max_width, - subsequent_indent=indent) - for row in rows: - if parameter and language == 'python': - # Add template to highlight the active parameter - row = re.sub(pattern, handle_sub, row) - - row = row.replace(' ', ' ') - row = row.replace('span ', 'span ') - row = row.replace('{}', '{{}}') - - if language and language == 'python': - for char in ['(', ')', ',', '*', '**']: - new_char = chars_template.format(char=char) - row = row.replace(char, new_char) - - formatted_lines.append(row) - title_template = '
    '.join(formatted_lines) - - # Get current font properties - font = self.font() - font_size = font.pointSize() - font_family = font.family() - - # Format title to display active parameter - if parameter and language == 'python': - title = title_template.format( - font_size=font_size, - font_family=font_family, - color=parameter_color, - parameter=parameter, - ) - else: - title = title_template - new_signatures.append(title) - - return '
    '.join(new_signatures) - - def _check_signature_and_format(self, signature_or_text, parameter=None, - inspect_word=None, - max_width=_DEFAULT_MAX_WIDTH, - language=_DEFAULT_LANGUAGE): - """ - LSP hints might provide docstrings instead of signatures. - - This method will check for multiple signatures (dict, type etc...) and - format the text accordingly. - """ - open_func_char = '' - has_signature = False - has_multisignature = False - language = getattr(self, 'language', language).lower() - signature_or_text = signature_or_text.replace('\\*', '*') - - # Remove special symbols that could itefere with ''.format - signature_or_text = signature_or_text.replace('{', '{') - signature_or_text = signature_or_text.replace('}', '}') - - # Remove 'ufunc' signature if needed. See spyder-ide/spyder#11821 - lines = [line for line in signature_or_text.split('\n') - if 'ufunc' not in line] - signature_or_text = '\n'.join(lines) - - if language == 'python': - open_func_char = '(' - has_multisignature = False - - if inspect_word: - has_signature = signature_or_text.startswith(inspect_word) - else: - idx = signature_or_text.find(open_func_char) - inspect_word = signature_or_text[:idx] - has_signature = True - - if has_signature: - name_plus_char = inspect_word + open_func_char - - all_lines = [] - for line in lines: - if (line.startswith(name_plus_char) - and line.count(name_plus_char) > 1): - sublines = line.split(name_plus_char) - sublines = [name_plus_char + l for l in sublines] - sublines = [l.strip() for l in sublines] - else: - sublines = [line] - - all_lines = all_lines + sublines - - lines = all_lines - count = 0 - for line in lines: - if line.startswith(name_plus_char): - count += 1 - - # Signature type - has_signature = count == 1 - has_multisignature = count > 1 and len(lines) > 1 - - if has_signature and not has_multisignature: - for i, line in enumerate(lines): - if line.strip() == '': - break - - if i == 0: - signature = lines[0] - extra_text = None - else: - signature = '\n'.join(lines[:i]) - extra_text = '\n'.join(lines[i:]) - - if signature: - new_signature = self._format_signature( - signatures=signature, - parameter=parameter, - max_width=max_width - ) - elif has_multisignature: - signature = signature_or_text.replace(name_plus_char, - '
    ' + name_plus_char) - signature = signature[4:] # Remove the first line break - signature = signature.replace('\n', ' ') - signature = signature.replace(r'\\*', '*') - signature = signature.replace(r'\*', '*') - signature = signature.replace('
    ', '\n') - signatures = signature.split('\n') - signatures = [sig for sig in signatures if sig] # Remove empty - new_signature = self._format_signature( - signatures=signatures, - parameter=parameter, - max_width=max_width - ) - extra_text = None - else: - new_signature = None - extra_text = signature_or_text - - return new_signature, extra_text, inspect_word - - def show_calltip(self, signature, parameter=None, documentation=None, - language=_DEFAULT_LANGUAGE, max_lines=_DEFAULT_MAX_LINES, - max_width=_DEFAULT_MAX_WIDTH, text_new_line=True): - """ - Show calltip. - - Calltips look like tooltips but will not disappear if mouse hovers - them. They are useful for displaying signature information on methods - and functions. - """ - # Find position of calltip - point = self._calculate_position() - signature = signature.strip() - inspect_word = None - language = getattr(self, 'language', language).lower() - if language == 'python' and signature: - inspect_word = signature.split('(')[0] - # Check if documentation is better than signature, sometimes - # signature has \n stripped for functions like print, type etc - check_doc = ' ' - if documentation: - check_doc.join(documentation.split()).replace('\\*', '*') - check_sig = ' '.join(signature.split()) - if check_doc == check_sig: - signature = documentation - documentation = '' - - # Remove duplicate signature inside documentation - if documentation: - documentation = documentation.replace('\\*', '*') - if signature.strip(): - documentation = documentation.replace(signature + '\n', '') - - # Format - res = self._check_signature_and_format(signature, parameter, - inspect_word=inspect_word, - language=language, - max_width=max_width) - new_signature, text, inspect_word = res - text = self._format_text( - signature=new_signature, - inspect_word=inspect_word, - display_link=False, - text=documentation, - max_lines=max_lines, - max_width=max_width, - text_new_line=text_new_line - ) - - self._update_stylesheet(self.calltip_widget) - - # Show calltip - self.calltip_widget.show_tip(point, text, []) - self.calltip_widget.show() - - def show_tooltip(self, title=None, signature=None, text=None, - inspect_word=None, title_color=_DEFAULT_TITLE_COLOR, - at_line=None, at_point=None, display_link=False, - max_lines=_DEFAULT_MAX_LINES, - max_width=_DEFAULT_MAX_WIDTH, - cursor=None, - with_html_format=False, - text_new_line=True, - completion_doc=None): - """Show tooltip.""" - # Find position of calltip - point = self._calculate_position( - at_line=at_line, - at_point=at_point, - ) - # Format text - tiptext = self._format_text( - title=title, - signature=signature, - text=text, - title_color=title_color, - inspect_word=inspect_word, - display_link=display_link, - max_lines=max_lines, - max_width=max_width, - with_html_format=with_html_format, - text_new_line=text_new_line - ) - - self._update_stylesheet(self.tooltip_widget) - - # Display tooltip - self.tooltip_widget.show_tip(point, tiptext, cursor=cursor, - completion_doc=completion_doc) - - def show_hint(self, text, inspect_word, at_point, - max_lines=_DEFAULT_MAX_HINT_LINES, - max_width=_DEFAULT_MAX_HINT_WIDTH, - text_new_line=True, completion_doc=None): - """Show code hint and crop text as needed.""" - res = self._check_signature_and_format(text, max_width=max_width, - inspect_word=inspect_word) - html_signature, extra_text, _ = res - point = self.get_word_start_pos(at_point) - - # Only display hover hint if there is documentation - if extra_text is not None: - # This is needed to get hover hints - cursor = self.cursorForPosition(at_point) - cursor.movePosition(QTextCursor.StartOfWord, - QTextCursor.MoveAnchor) - self._last_hover_cursor = cursor - - self.show_tooltip(signature=html_signature, text=extra_text, - at_point=point, inspect_word=inspect_word, - display_link=True, max_lines=max_lines, - max_width=max_width, cursor=cursor, - text_new_line=text_new_line, - completion_doc=completion_doc) - - def hide_tooltip(self): - """ - Hide the tooltip widget. - - The tooltip widget is a special QLabel that looks like a tooltip, - this method is here so it can be hidden as necessary. For example, - when the user leaves the Linenumber area when hovering over lint - warnings and errors. - """ - self._last_hover_cursor = None - self._last_hover_word = None - self._last_point = None - self.tooltip_widget.hide() - - # ----- Required methods for the LSP - def document_did_change(self, text=None): - pass - - #------EOL characters - def set_eol_chars(self, text=None, eol_chars=None): - """ - Set widget end-of-line (EOL) characters. - - Parameters - ---------- - text: str - Text to detect EOL characters from. - eol_chars: str - EOL characters to set. - - Notes - ----- - If `text` is passed, then `eol_chars` has no effect. - """ - if text is not None: - detected_eol_chars = sourcecode.get_eol_chars(text) - is_document_modified = ( - detected_eol_chars is not None and self.eol_chars is not None - ) - self.eol_chars = detected_eol_chars - elif eol_chars is not None: - is_document_modified = eol_chars != self.eol_chars - self.eol_chars = eol_chars - - if is_document_modified: - self.document().setModified(True) - if self.sig_eol_chars_changed is not None: - self.sig_eol_chars_changed.emit(eol_chars) - - def get_line_separator(self): - """Return line separator based on current EOL mode""" - if self.eol_chars is not None: - return self.eol_chars - else: - return os.linesep - - def get_text_with_eol(self): - """ - Same as 'toPlainText', replacing '\n' by correct end-of-line - characters. - """ - text = self.toPlainText() - linesep = self.get_line_separator() - for symbol in EOL_SYMBOLS: - text = text.replace(symbol, linesep) - return text - - #------Positions, coordinates (cursor, EOF, ...) - def get_position(self, subject): - """Get offset in character for the given subject from the start of - text edit area""" - cursor = self.textCursor() - if subject == 'cursor': - pass - elif subject == 'sol': - cursor.movePosition(QTextCursor.StartOfBlock) - elif subject == 'eol': - cursor.movePosition(QTextCursor.EndOfBlock) - elif subject == 'eof': - cursor.movePosition(QTextCursor.End) - elif subject == 'sof': - cursor.movePosition(QTextCursor.Start) - else: - # Assuming that input argument was already a position - return subject - return cursor.position() - - def get_coordinates(self, position): - position = self.get_position(position) - cursor = self.textCursor() - cursor.setPosition(position) - point = self.cursorRect(cursor).center() - return point.x(), point.y() - - def _is_point_inside_word_rect(self, point): - """ - Check if the mouse is within the rect of the cursor current word. - """ - cursor = self.cursorForPosition(point) - cursor.movePosition(QTextCursor.StartOfWord, QTextCursor.MoveAnchor) - start_rect = self.cursorRect(cursor) - cursor.movePosition(QTextCursor.EndOfWord, QTextCursor.MoveAnchor) - end_rect = self.cursorRect(cursor) - bounding_rect = start_rect.united(end_rect) - return bounding_rect.contains(point) - - def get_word_start_pos(self, position): - """ - Find start position (lower bottom) of a word being hovered by mouse. - """ - cursor = self.cursorForPosition(position) - cursor.movePosition(QTextCursor.StartOfWord, QTextCursor.MoveAnchor) - rect = self.cursorRect(cursor) - pos = QPoint(rect.left() + 4, rect.top()) - return pos - - def get_last_hover_word(self): - """Return the last (or active) hover word.""" - return self._last_hover_word - - def get_last_hover_cursor(self): - """Return the last (or active) hover cursor.""" - return self._last_hover_cursor - - def get_cursor_line_column(self, cursor=None): - """ - Return `cursor` (line, column) numbers. - - If no `cursor` is provided, use the current text cursor. - """ - if cursor is None: - cursor = self.textCursor() - - return cursor.blockNumber(), cursor.columnNumber() - - def get_cursor_line_number(self): - """Return cursor line number""" - return self.textCursor().blockNumber()+1 - - def get_position_line_number(self, line, col): - """Get position offset from (line, col) coordinates.""" - block = self.document().findBlockByNumber(line) - cursor = QTextCursor(block) - cursor.movePosition(QTextCursor.StartOfBlock) - cursor.movePosition(QTextCursor.Right, QTextCursor.KeepAnchor, - n=col + 1) - return cursor.position() - - def set_cursor_position(self, position): - """Set cursor position""" - position = self.get_position(position) - cursor = self.textCursor() - cursor.setPosition(position) - self.setTextCursor(cursor) - self.ensureCursorVisible() - - def move_cursor(self, chars=0): - """Move cursor to left or right (unit: characters)""" - direction = QTextCursor.Right if chars > 0 else QTextCursor.Left - for _i in range(abs(chars)): - self.moveCursor(direction, QTextCursor.MoveAnchor) - - def is_cursor_on_first_line(self): - """Return True if cursor is on the first line""" - cursor = self.textCursor() - cursor.movePosition(QTextCursor.StartOfBlock) - return cursor.atStart() - - def is_cursor_on_last_line(self): - """Return True if cursor is on the last line""" - cursor = self.textCursor() - cursor.movePosition(QTextCursor.EndOfBlock) - return cursor.atEnd() - - def is_cursor_at_end(self): - """Return True if cursor is at the end of the text""" - return self.textCursor().atEnd() - - def is_cursor_before(self, position, char_offset=0): - """Return True if cursor is before *position*""" - position = self.get_position(position) + char_offset - cursor = self.textCursor() - cursor.movePosition(QTextCursor.End) - if position < cursor.position(): - cursor.setPosition(position) - return self.textCursor() < cursor - - def __move_cursor_anchor(self, what, direction, move_mode): - assert what in ('character', 'word', 'line') - if what == 'character': - if direction == 'left': - self.moveCursor(QTextCursor.PreviousCharacter, move_mode) - elif direction == 'right': - self.moveCursor(QTextCursor.NextCharacter, move_mode) - elif what == 'word': - if direction == 'left': - self.moveCursor(QTextCursor.PreviousWord, move_mode) - elif direction == 'right': - self.moveCursor(QTextCursor.NextWord, move_mode) - elif what == 'line': - if direction == 'down': - self.moveCursor(QTextCursor.NextBlock, move_mode) - elif direction == 'up': - self.moveCursor(QTextCursor.PreviousBlock, move_mode) - - def move_cursor_to_next(self, what='word', direction='left'): - """ - Move cursor to next *what* ('word' or 'character') - toward *direction* ('left' or 'right') - """ - self.__move_cursor_anchor(what, direction, QTextCursor.MoveAnchor) - - #------Selection - def extend_selection_to_next(self, what='word', direction='left'): - """ - Extend selection to next *what* ('word' or 'character') - toward *direction* ('left' or 'right') - """ - self.__move_cursor_anchor(what, direction, QTextCursor.KeepAnchor) - - #------Text: get, set, ... - - def _select_text(self, position_from, position_to): - """Select text and return cursor.""" - position_from = self.get_position(position_from) - position_to = self.get_position(position_to) - cursor = self.textCursor() - cursor.setPosition(position_from) - cursor.setPosition(position_to, QTextCursor.KeepAnchor) - return cursor - - def get_text_line(self, line_nb): - """Return text line at line number *line_nb*""" - block = self.document().findBlockByNumber(line_nb) - cursor = QTextCursor(block) - cursor.movePosition(QTextCursor.StartOfBlock) - cursor.movePosition(QTextCursor.EndOfBlock, mode=QTextCursor.KeepAnchor) - return to_text_string(cursor.selectedText()) - - def get_text_region(self, start_line, end_line): - """Return text lines spanned from *start_line* to *end_line*.""" - start_block = self.document().findBlockByNumber(start_line) - end_block = self.document().findBlockByNumber(end_line) - - start_cursor = QTextCursor(start_block) - start_cursor.movePosition(QTextCursor.StartOfBlock) - end_cursor = QTextCursor(end_block) - end_cursor.movePosition(QTextCursor.EndOfBlock) - end_position = end_cursor.position() - start_cursor.setPosition(end_position, mode=QTextCursor.KeepAnchor) - return self.get_selected_text(start_cursor) - - def get_text(self, position_from, position_to, remove_newlines=True): - """Returns text between *position_from* and *position_to*. - - Positions may be integers or 'sol', 'eol', 'sof', 'eof' or 'cursor'. - - Unless position_from='sof' and position_to='eof' any trailing newlines - in the string are removed. This was added as a workaround for - spyder-ide/spyder#1546 and later caused spyder-ide/spyder#14374. - The behaviour can be overridden by setting the optional parameter - *remove_newlines* to False. - - TODO: Evaluate if this is still a problem and if the workaround can - be moved closer to where the problem occurs. - """ - cursor = self._select_text(position_from, position_to) - text = to_text_string(cursor.selectedText()) - if remove_newlines: - remove_newlines = position_from != 'sof' or position_to != 'eof' - if text and remove_newlines: - while text and text[-1] in EOL_SYMBOLS: - text = text[:-1] - return text - - def get_character(self, position, offset=0): - """Return character at *position* with the given offset.""" - position = self.get_position(position) + offset - cursor = self.textCursor() - cursor.movePosition(QTextCursor.End) - if position < cursor.position(): - cursor.setPosition(position) - cursor.movePosition(QTextCursor.Right, - QTextCursor.KeepAnchor) - return to_text_string(cursor.selectedText()) - else: - return '' - - def insert_text(self, text, will_insert_text=True): - """Insert text at cursor position""" - if not self.isReadOnly(): - if will_insert_text and self.sig_will_insert_text is not None: - self.sig_will_insert_text.emit(text) - self.textCursor().insertText(text) - if self.sig_text_was_inserted is not None: - self.sig_text_was_inserted.emit() - - def replace_text(self, position_from, position_to, text): - cursor = self._select_text(position_from, position_to) - if self.sig_will_remove_selection is not None: - start, end = self.get_selection_start_end(cursor) - self.sig_will_remove_selection.emit(start, end) - cursor.removeSelectedText() - if self.sig_will_insert_text is not None: - self.sig_will_insert_text.emit(text) - cursor.insertText(text) - if self.sig_text_was_inserted is not None: - self.sig_text_was_inserted.emit() - - def remove_text(self, position_from, position_to): - cursor = self._select_text(position_from, position_to) - if self.sig_will_remove_selection is not None: - start, end = self.get_selection_start_end(cursor) - self.sig_will_remove_selection.emit(start, end) - cursor.removeSelectedText() - - def get_current_object(self): - """ - Return current object under cursor. - - Get the text of the current word plus all the characters - to the left until a space is found. Used to get text to inspect - for Help of elements following dot notation for example - np.linalg.norm - """ - cursor = self.textCursor() - cursor_pos = cursor.position() - current_word = self.get_current_word(help_req=True) - - # Get max position to the left of cursor until space or no more - # characters are left - cursor.movePosition(QTextCursor.PreviousCharacter) - while self.get_character(cursor.position()).strip(): - cursor.movePosition(QTextCursor.PreviousCharacter) - if cursor.atBlockStart(): - break - cursor_pos_left = cursor.position() - - # Get max position to the right of cursor until space or no more - # characters are left - cursor.setPosition(cursor_pos) - while self.get_character(cursor.position()).strip(): - cursor.movePosition(QTextCursor.NextCharacter) - if cursor.atBlockEnd(): - break - cursor_pos_right = cursor.position() - - # Get text of the object under the cursor - current_text = self.get_text( - cursor_pos_left, cursor_pos_right).strip() - current_object = current_word - - if current_text and current_word is not None: - if current_word != current_text and current_word in current_text: - current_object = ( - current_text.split(current_word)[0] + current_word) - - return current_object - - def get_current_word_and_position(self, completion=False, help_req=False, - valid_python_variable=True): - """ - Return current word, i.e. word at cursor position, and the start - position. - """ - cursor = self.textCursor() - cursor_pos = cursor.position() - - if cursor.hasSelection(): - # Removes the selection and moves the cursor to the left side - # of the selection: this is required to be able to properly - # select the whole word under cursor (otherwise, the same word is - # not selected when the cursor is at the right side of it): - cursor.setPosition(min([cursor.selectionStart(), - cursor.selectionEnd()])) - else: - # Checks if the first character to the right is a white space - # and if not, moves the cursor one word to the left (otherwise, - # if the character to the left do not match the "word regexp" - # (see below), the word to the left of the cursor won't be - # selected), but only if the first character to the left is not a - # white space too. - def is_space(move): - curs = self.textCursor() - curs.movePosition(move, QTextCursor.KeepAnchor) - return not to_text_string(curs.selectedText()).strip() - - def is_special_character(move): - """Check if a character is a non-letter including numbers.""" - curs = self.textCursor() - curs.movePosition(move, QTextCursor.KeepAnchor) - text_cursor = to_text_string(curs.selectedText()).strip() - return len( - re.findall(r'([^\d\W]\w*)', text_cursor, re.UNICODE)) == 0 - - if help_req: - if is_special_character(QTextCursor.PreviousCharacter): - cursor.movePosition(QTextCursor.NextCharacter) - elif is_special_character(QTextCursor.NextCharacter): - cursor.movePosition(QTextCursor.PreviousCharacter) - elif not completion: - if is_space(QTextCursor.NextCharacter): - if is_space(QTextCursor.PreviousCharacter): - return - cursor.movePosition(QTextCursor.WordLeft) - else: - if is_space(QTextCursor.PreviousCharacter): - return - if (is_special_character(QTextCursor.NextCharacter)): - cursor.movePosition(QTextCursor.WordLeft) - - cursor.select(QTextCursor.WordUnderCursor) - text = to_text_string(cursor.selectedText()) - startpos = cursor.selectionStart() - - # Find a valid Python variable name - if valid_python_variable: - match = re.findall(r'([^\d\W]\w*)', text, re.UNICODE) - if not match: - # This is assumed in several places of our codebase, - # so please don't change this return! - return None - else: - text = match[0] - - if completion: - text = text[:cursor_pos - startpos] - - return text, startpos - - def get_current_word(self, completion=False, help_req=False, - valid_python_variable=True): - """Return current word, i.e. word at cursor position.""" - ret = self.get_current_word_and_position( - completion=completion, - help_req=help_req, - valid_python_variable=valid_python_variable - ) - - if ret is not None: - return ret[0] - - def get_hover_word(self): - """Return the last hover word that requested a hover hint.""" - return self._last_hover_word - - def get_current_line(self): - """Return current line's text.""" - cursor = self.textCursor() - cursor.select(QTextCursor.BlockUnderCursor) - return to_text_string(cursor.selectedText()) - - def get_current_line_to_cursor(self): - """Return text from prompt to cursor.""" - return self.get_text(self.current_prompt_pos, 'cursor') - - def get_line_number_at(self, coordinates): - """Return line number at *coordinates* (QPoint).""" - cursor = self.cursorForPosition(coordinates) - return cursor.blockNumber() + 1 - - def get_line_at(self, coordinates): - """Return line at *coordinates* (QPoint).""" - cursor = self.cursorForPosition(coordinates) - cursor.select(QTextCursor.BlockUnderCursor) - return to_text_string(cursor.selectedText()).replace(u'\u2029', '') - - def get_word_at(self, coordinates): - """Return word at *coordinates* (QPoint).""" - cursor = self.cursorForPosition(coordinates) - cursor.select(QTextCursor.WordUnderCursor) - if self._is_point_inside_word_rect(coordinates): - word = to_text_string(cursor.selectedText()) - else: - word = '' - - return word - - def get_line_indentation(self, text): - """Get indentation for given line.""" - text = text.replace("\t", " "*self.tab_stop_width_spaces) - return len(text)-len(text.lstrip()) - - def get_block_indentation(self, block_nb): - """Return line indentation (character number).""" - text = to_text_string(self.document().findBlockByNumber(block_nb).text()) - return self.get_line_indentation(text) - - def get_selection_bounds(self, cursor=None): - """Return selection bounds (block numbers).""" - if cursor is None: - cursor = self.textCursor() - start, end = cursor.selectionStart(), cursor.selectionEnd() - block_start = self.document().findBlock(start) - block_end = self.document().findBlock(end) - return sorted([block_start.blockNumber(), block_end.blockNumber()]) - - def get_selection_start_end(self, cursor=None): - """Return selection start and end (line, column) positions.""" - if cursor is None: - cursor = self.textCursor() - start, end = cursor.selectionStart(), cursor.selectionEnd() - start_cursor = QTextCursor(cursor) - start_cursor.setPosition(start) - start_position = self.get_cursor_line_column(start_cursor) - end_cursor = QTextCursor(cursor) - end_cursor.setPosition(end) - end_position = self.get_cursor_line_column(end_cursor) - return start_position, end_position - - #------Text selection - def has_selected_text(self): - """Returns True if some text is selected.""" - return bool(to_text_string(self.textCursor().selectedText())) - - def get_selected_text(self, cursor=None): - """ - Return text selected by current text cursor, converted in unicode. - - Replace the unicode line separator character \u2029 by - the line separator characters returned by get_line_separator - """ - if cursor is None: - cursor = self.textCursor() - return to_text_string(cursor.selectedText()).replace(u"\u2029", - self.get_line_separator()) - - def remove_selected_text(self): - """Delete selected text.""" - self.textCursor().removeSelectedText() - # The next three lines are a workaround for a quirk of - # QTextEdit on Linux with Qt < 5.15, MacOs and Windows. - # See spyder-ide/spyder#12663 and - # https://bugreports.qt.io/browse/QTBUG-35861 - if (parse_version(QT_VERSION) < parse_version('5.15') - or os.name == 'nt' or sys.platform == 'darwin'): - cursor = self.textCursor() - cursor.setPosition(cursor.position()) - self.setTextCursor(cursor) - - def replace(self, text, pattern=None): - """Replace selected text by *text*. - - If *pattern* is not None, replacing selected text using regular - expression text substitution.""" - cursor = self.textCursor() - cursor.beginEditBlock() - if pattern is not None: - seltxt = to_text_string(cursor.selectedText()) - if self.sig_will_remove_selection is not None: - start, end = self.get_selection_start_end(cursor) - self.sig_will_remove_selection.emit(start, end) - cursor.removeSelectedText() - if pattern is not None: - text = re.sub(to_text_string(pattern), - to_text_string(text), to_text_string(seltxt)) - if self.sig_will_insert_text is not None: - self.sig_will_insert_text.emit(text) - cursor.insertText(text) - if self.sig_text_was_inserted is not None: - self.sig_text_was_inserted.emit() - cursor.endEditBlock() - - - #------Find/replace - def find_multiline_pattern(self, regexp, cursor, findflag): - """Reimplement QTextDocument's find method. - - Add support for *multiline* regular expressions.""" - pattern = to_text_string(regexp.pattern()) - text = to_text_string(self.toPlainText()) - try: - regobj = re.compile(pattern) - except sre_constants.error: - return - if findflag & QTextDocument.FindBackward: - # Find backward - offset = min([cursor.selectionEnd(), cursor.selectionStart()]) - text = text[:offset] - matches = [_m for _m in regobj.finditer(text, 0, offset)] - if matches: - match = matches[-1] - else: - return - else: - # Find forward - offset = max([cursor.selectionEnd(), cursor.selectionStart()]) - match = regobj.search(text, offset) - if match: - pos1, pos2 = sh.get_span(match) - fcursor = self.textCursor() - fcursor.setPosition(pos1) - fcursor.setPosition(pos2, QTextCursor.KeepAnchor) - return fcursor - - def find_text(self, text, changed=True, forward=True, case=False, - word=False, regexp=False): - """Find text.""" - cursor = self.textCursor() - findflag = QTextDocument.FindFlag() - - # Get visible region to center cursor in case it's necessary. - if getattr(self, 'get_visible_block_numbers', False): - current_visible_region = self.get_visible_block_numbers() - else: - current_visible_region = None - - if not forward: - findflag = findflag | QTextDocument.FindBackward - - if case: - findflag = findflag | QTextDocument.FindCaseSensitively - - moves = [QTextCursor.NoMove] - if forward: - moves += [QTextCursor.NextWord, QTextCursor.Start] - if changed: - if to_text_string(cursor.selectedText()): - new_position = min([cursor.selectionStart(), - cursor.selectionEnd()]) - cursor.setPosition(new_position) - else: - cursor.movePosition(QTextCursor.PreviousWord) - else: - moves += [QTextCursor.End] - - if regexp: - text = to_text_string(text) - else: - text = re.escape(to_text_string(text)) - - pattern = QRegularExpression(u"\\b{}\\b".format(text) if word else - text) - if case: - pattern.setPatternOptions(QRegularExpression.CaseInsensitiveOption) - - for move in moves: - cursor.movePosition(move) - if regexp and '\\n' in text: - # Multiline regular expression - found_cursor = self.find_multiline_pattern(pattern, cursor, - findflag) - else: - # Single line find: using the QTextDocument's find function, - # probably much more efficient than ours - found_cursor = self.document().find(pattern, cursor, findflag) - if found_cursor is not None and not found_cursor.isNull(): - self.setTextCursor(found_cursor) - - # Center cursor if we move out of the visible region. - if current_visible_region is not None: - found_visible_region = self.get_visible_block_numbers() - if current_visible_region != found_visible_region: - current_visible_region = found_visible_region - self.centerCursor() - - return True - - return False - - def is_editor(self): - """Needs to be overloaded in the codeeditor where it will be True""" - return False - - def get_number_matches(self, pattern, source_text='', case=False, - regexp=False, word=False): - """Get the number of matches for the searched text.""" - pattern = to_text_string(pattern) - if not pattern: - return 0 - - if not regexp: - pattern = re.escape(pattern) - - if not source_text: - source_text = to_text_string(self.toPlainText()) - - if word: # match whole words only - pattern = r'\b{pattern}\b'.format(pattern=pattern) - try: - re_flags = re.MULTILINE if case else re.IGNORECASE | re.MULTILINE - regobj = re.compile(pattern, flags=re_flags) - except sre_constants.error: - return None - - number_matches = 0 - for match in regobj.finditer(source_text): - number_matches += 1 - - return number_matches - - def get_match_number(self, pattern, case=False, regexp=False, word=False): - """Get number of the match for the searched text.""" - position = self.textCursor().position() - source_text = self.get_text(position_from='sof', position_to=position) - match_number = self.get_number_matches(pattern, - source_text=source_text, - case=case, regexp=regexp, - word=word) - return match_number - - # --- Array builder helper / See 'spyder/widgets/arraybuilder.py' - def enter_array_inline(self): - """Enter array builder inline mode.""" - self._enter_array(True) - - def enter_array_table(self): - """Enter array builder table mode.""" - self._enter_array(False) - - def _enter_array(self, inline): - """Enter array builder mode.""" - offset = self.get_position('cursor') - self.get_position('sol') - rect = self.cursorRect() - dlg = ArrayBuilderDialog(self, inline, offset) - - # TODO: adapt to font size - x = rect.left() - x = int(x - 14) - y = rect.top() + (rect.bottom() - rect.top())/2 - y = int(y - dlg.height()/2 - 3) - - pos = QPoint(x, y) - pos = self.calculate_real_position(pos) - dlg.move(self.mapToGlobal(pos)) - - # called from editor - if self.is_editor(): - python_like_check = self.is_python_like() - suffix = '\n' - # called from a console - else: - python_like_check = True - suffix = '' - - if python_like_check and dlg.exec_(): - text = dlg.text() + suffix - if text != '': - cursor = self.textCursor() - cursor.beginEditBlock() - if self.sig_will_insert_text is not None: - self.sig_will_insert_text.emit(text) - cursor.insertText(text) - if self.sig_text_was_inserted is not None: - self.sig_text_was_inserted.emit() - cursor.endEditBlock() - - -class TracebackLinksMixin(object): - """ """ - QT_CLASS = None - - # This signal emits a parsed error traceback text so we can then - # request opening the file that traceback comes from in the Editor. - sig_go_to_error_requested = None - - def __init__(self): - self.__cursor_changed = False - self.setMouseTracking(True) - - #------Mouse events - def mouseReleaseEvent(self, event): - """Go to error""" - self.QT_CLASS.mouseReleaseEvent(self, event) - text = self.get_line_at(event.pos()) - if get_error_match(text) and not self.has_selected_text(): - if self.sig_go_to_error_requested is not None: - self.sig_go_to_error_requested.emit(text) - - def mouseMoveEvent(self, event): - """Show Pointing Hand Cursor on error messages""" - text = self.get_line_at(event.pos()) - if get_error_match(text): - if not self.__cursor_changed: - QApplication.setOverrideCursor(QCursor(Qt.PointingHandCursor)) - self.__cursor_changed = True - event.accept() - return - if self.__cursor_changed: - QApplication.restoreOverrideCursor() - self.__cursor_changed = False - self.QT_CLASS.mouseMoveEvent(self, event) - - def leaveEvent(self, event): - """If cursor has not been restored yet, do it now""" - if self.__cursor_changed: - QApplication.restoreOverrideCursor() - self.__cursor_changed = False - self.QT_CLASS.leaveEvent(self, event) - - -class GetHelpMixin(object): - - def __init__(self): - self.help_enabled = False - - def set_help_enabled(self, state): - self.help_enabled = state - - def inspect_current_object(self): - current_object = self.get_current_object() - if current_object is not None: - self.show_object_info(current_object, force=True) - - def show_object_info(self, text, call=False, force=False): - """Show signature calltip and/or docstring in the Help plugin""" - text = to_text_string(text) - - # Show docstring - help_enabled = self.help_enabled or force - if help_enabled: - doc = { - 'name': text, - 'ignore_unknown': False, - } - self.sig_help_requested.emit(doc) - - # Show calltip - if call and getattr(self, 'calltips', None): - # Display argument list if this is a function call - iscallable = self.iscallable(text) - if iscallable is not None: - if iscallable: - arglist = self.get_arglist(text) - name = text.split('.')[-1] - argspec = signature = '' - if isinstance(arglist, bool): - arglist = [] - if arglist: - argspec = '(' + ''.join(arglist) + ')' - else: - doc = self.get__doc__(text) - if doc is not None: - # This covers cases like np.abs, whose docstring is - # the same as np.absolute and because of that a - # proper signature can't be obtained correctly - argspec = getargspecfromtext(doc) - if not argspec: - signature = getsignaturefromtext(doc, name) - if argspec or signature: - if argspec: - tiptext = name + argspec - else: - tiptext = signature - # TODO: Select language and pass it to call - self.show_calltip(tiptext) - - def get_last_obj(self, last=False): - """ - Return the last valid object on the current line - """ - return getobj(self.get_current_line_to_cursor(), last=last) - - -class SaveHistoryMixin(object): - - INITHISTORY = None - SEPARATOR = None - HISTORY_FILENAMES = [] - - sig_append_to_history_requested = None - - def __init__(self, history_filename=''): - self.history_filename = history_filename - self.create_history_filename() - - def create_history_filename(self): - """Create history_filename with INITHISTORY if it doesn't exist.""" - if self.history_filename and not osp.isfile(self.history_filename): - try: - encoding.writelines(self.INITHISTORY, self.history_filename) - except EnvironmentError: - pass - - def add_to_history(self, command): - """Add command to history""" - command = to_text_string(command) - if command in ['', '\n'] or command.startswith('Traceback'): - return - if command.endswith('\n'): - command = command[:-1] - self.histidx = None - if len(self.history) > 0 and self.history[-1] == command: - return - self.history.append(command) - text = os.linesep + command - - # When the first entry will be written in history file, - # the separator will be append first: - if self.history_filename not in self.HISTORY_FILENAMES: - self.HISTORY_FILENAMES.append(self.history_filename) - text = self.SEPARATOR + text - # Needed to prevent errors when writing history to disk - # See spyder-ide/spyder#6431. - try: - encoding.write(text, self.history_filename, mode='ab') - except EnvironmentError: - pass - if self.sig_append_to_history_requested is not None: - self.sig_append_to_history_requested.emit( - self.history_filename, text) - - -class BrowseHistory(object): - - def __init__(self): - self.history = [] - self.histidx = None - self.hist_wholeline = False - - def browse_history(self, line, cursor_pos, backward): - """ - Browse history. - - Return the new text and wherever the cursor should move. - """ - if cursor_pos < len(line) and self.hist_wholeline: - self.hist_wholeline = False - tocursor = line[:cursor_pos] - text, self.histidx = self.find_in_history(tocursor, self.histidx, - backward) - if text is not None: - text = text.strip() - if self.hist_wholeline: - return text, True - else: - return tocursor + text, False - return None, False - - def find_in_history(self, tocursor, start_idx, backward): - """Find text 'tocursor' in history, from index 'start_idx'""" - if start_idx is None: - start_idx = len(self.history) - # Finding text in history - step = -1 if backward else 1 - idx = start_idx - if len(tocursor) == 0 or self.hist_wholeline: - idx += step - if idx >= len(self.history) or len(self.history) == 0: - return "", len(self.history) - elif idx < 0: - idx = 0 - self.hist_wholeline = True - return self.history[idx], idx - else: - for index in range(len(self.history)): - idx = (start_idx+step*(index+1)) % len(self.history) - entry = self.history[idx] - if entry.startswith(tocursor): - return entry[len(tocursor):], idx - else: - return None, start_idx - - def reset_search_pos(self): - """Reset the position from which to search the history""" - self.histidx = None - - -class BrowseHistoryMixin(BrowseHistory): - - def clear_line(self): - """Clear current line (without clearing console prompt)""" - self.remove_text(self.current_prompt_pos, 'eof') - - def browse_history(self, backward): - """Browse history""" - line = self.get_text(self.current_prompt_pos, 'eof') - old_pos = self.get_position('cursor') - cursor_pos = self.get_position('cursor') - self.current_prompt_pos - if cursor_pos < 0: - cursor_pos = 0 - self.set_cursor_position(self.current_prompt_pos) - text, move_cursor = super(BrowseHistoryMixin, self).browse_history( - line, cursor_pos, backward) - if text is not None: - self.clear_line() - self.insert_text(text) - if not move_cursor: - self.set_cursor_position(old_pos) +# -*- coding: utf-8 -*- +# +# Copyright © Spyder Project Contributors +# Licensed under the terms of the MIT License +# (see spyder/__init__.py for details) + +"""Mix-in classes + +These classes were created to be able to provide Spyder's regular text and +console widget features to an independent widget based on QTextEdit for the +IPython console plugin. +""" + +# Standard library imports +from __future__ import print_function +import os +import os.path as osp +import re +import sre_constants +import sys +import textwrap +from pkg_resources import parse_version + +# Third party imports +from qtpy import QT_VERSION +from qtpy.QtCore import QPoint, QRegularExpression, Qt +from qtpy.QtGui import QCursor, QTextCursor, QTextDocument +from qtpy.QtWidgets import QApplication +from spyder_kernels.utils.dochelpers import (getargspecfromtext, getobj, + getsignaturefromtext) + +# Local imports +from spyder.config.manager import CONF +from spyder.py3compat import to_text_string +from spyder.utils import encoding, sourcecode +from spyder.utils import syntaxhighlighters as sh +from spyder.utils.misc import get_error_match +from spyder.utils.palette import QStylePalette +from spyder.widgets.arraybuilder import ArrayBuilderDialog + + +# List of possible EOL symbols +EOL_SYMBOLS = [ + # Put first as it correspond to a single line return + "\r\n", # Carriage Return + Line Feed + "\r", # Carriage Return + "\n", # Line Feed + "\v", # Line Tabulation + "\x0b", # Line Tabulation + "\f", # Form Feed + "\x0c", # Form Feed + "\x1c", # File Separator + "\x1d", # Group Separator + "\x1e", # Record Separator + "\x85", # Next Line (C1 Control Code) + "\u2028", # Line Separator + "\u2029", # Paragraph Separator +] + + +class BaseEditMixin(object): + + _PARAMETER_HIGHLIGHT_COLOR = QStylePalette.COLOR_ACCENT_4 + _DEFAULT_TITLE_COLOR = QStylePalette.COLOR_ACCENT_4 + _CHAR_HIGHLIGHT_COLOR = QStylePalette.COLOR_ACCENT_4 + _DEFAULT_TEXT_COLOR = QStylePalette.COLOR_TEXT_2 + _DEFAULT_LANGUAGE = 'python' + _DEFAULT_MAX_LINES = 10 + _DEFAULT_MAX_WIDTH = 60 + _DEFAULT_COMPLETION_HINT_MAX_WIDTH = 52 + _DEFAULT_MAX_HINT_LINES = 20 + _DEFAULT_MAX_HINT_WIDTH = 85 + + # The following signals are used to indicate text changes on the editor. + sig_will_insert_text = None + sig_will_remove_selection = None + sig_text_was_inserted = None + + _styled_widgets = set() + + def __init__(self): + self.eol_chars = None + self.calltip_size = 600 + + #------Line number area + def get_linenumberarea_width(self): + """Return line number area width""" + # Implemented in CodeEditor, but needed for calltip/completion widgets + return 0 + + def calculate_real_position(self, point): + """ + Add offset to a point, to take into account the Editor panels. + + This is reimplemented in CodeEditor, in other widgets it returns + the same point. + """ + return point + + # --- Tooltips and Calltips + def _calculate_position(self, at_line=None, at_point=None): + """ + Calculate a global point position `QPoint(x, y)`, for a given + line, local cursor position, or local point. + """ + font = self.font() + + if at_point is not None: + # Showing tooltip at point position + margin = (self.document().documentMargin() / 2) + 1 + cx = int(at_point.x() - margin) + cy = int(at_point.y() - margin) + elif at_line is not None: + # Showing tooltip at line + cx = 5 + line = at_line - 1 + cursor = QTextCursor(self.document().findBlockByNumber(line)) + cy = int(self.cursorRect(cursor).top()) + else: + # Showing tooltip at cursor position + cx, cy = self.get_coordinates('cursor') + cx = int(cx) + cy = int(cy - font.pointSize() / 2) + + # Calculate vertical delta + # The needed delta changes with font size, so we use a power law + if sys.platform == 'darwin': + delta = int((font.pointSize() * 1.20) ** 0.98 + 4.5) + elif os.name == 'nt': + delta = int((font.pointSize() * 1.20) ** 1.05) + 7 + else: + delta = int((font.pointSize() * 1.20) ** 0.98) + 7 + # delta = font.pointSize() + 5 + + # Map to global coordinates + point = self.mapToGlobal(QPoint(cx, cy)) + point = self.calculate_real_position(point) + point.setY(point.y() + delta) + + return point + + def _update_stylesheet(self, widget): + """Update the background stylesheet to make it lighter.""" + # Update the stylesheet for a given widget at most once + # because Qt is slow to repeatedly parse & apply CSS + if id(widget) in self._styled_widgets: + return + self._styled_widgets.add(id(widget)) + background = QStylePalette.COLOR_BACKGROUND_4 + border = QStylePalette.COLOR_TEXT_4 + name = widget.__class__.__name__ + widget.setObjectName(name) + css = ''' + {0}#{0} {{ + background-color:{1}; + border: 1px solid {2}; + }}'''.format(name, background, border) + widget.setStyleSheet(css) + + def _get_inspect_shortcut(self): + """ + Queries the editor's config to get the current "Inspect" shortcut. + """ + value = CONF.get('shortcuts', 'editor/inspect current object') + if value: + if sys.platform == "darwin": + value = value.replace('Ctrl', 'Cmd') + return value + + def _format_text(self, title=None, signature=None, text=None, + inspect_word=None, title_color=None, max_lines=None, + max_width=_DEFAULT_MAX_WIDTH, display_link=False, + text_new_line=False, with_html_format=False): + """ + Create HTML template for calltips and tooltips. + + This will display title and text as separate sections and add `...` + + ---------------------------------------- + | `title` (with `title_color`) | + ---------------------------------------- + | `signature` | + | | + | `text` (ellided to `max_lines`) | + | | + ---------------------------------------- + | Link or shortcut with `inspect_word` | + ---------------------------------------- + """ + BASE_TEMPLATE = u''' +
    + {main_text} +
    + ''' + # Get current font properties + font = self.font() + font_family = font.family() + title_size = font.pointSize() + text_size = title_size - 1 if title_size > 9 else title_size + text_color = self._DEFAULT_TEXT_COLOR + + template = '' + if title: + template += BASE_TEMPLATE.format( + font_family=font_family, + size=title_size, + color=title_color, + main_text=title, + ) + + if text or signature: + template += '
    ' + + if signature: + signature = signature.strip('\r\n') + template += BASE_TEMPLATE.format( + font_family=font_family, + size=text_size, + color=text_color, + main_text=signature, + ) + + # Documentation/text handling + if (text is None or not text.strip() or + text.strip() == ''): + text = 'No documentation available' + else: + text = text.strip() + + if not with_html_format: + # All these replacements are need to properly divide the + # text in actual paragraphs and wrap the text on each one + paragraphs = (text + .replace(u"\xa0", u" ") + .replace("\n\n", "") + .replace(".\n", ".") + .replace("\n-", "-") + .replace("-\n", "-") + .replace("\n=", "=") + .replace("=\n", "=") + .replace("\n*", "*") + .replace("*\n", "*") + .replace("\n ", " ") + .replace(" \n", " ") + .replace("\n", " ") + .replace("", "\n\n") + .replace("", "\n").splitlines()) + new_paragraphs = [] + for paragraph in paragraphs: + # Wrap text + new_paragraph = textwrap.wrap(paragraph, width=max_width) + + # Remove empty lines at the beginning + new_paragraph = [l for l in new_paragraph if l.strip()] + + # Merge paragraph text + new_paragraph = '\n'.join(new_paragraph) + + # Add new paragraph + new_paragraphs.append(new_paragraph) + + # Join paragraphs and split in lines for max_lines check + paragraphs = '\n'.join(new_paragraphs) + paragraphs = paragraphs.strip('\r\n') + lines = paragraphs.splitlines() + + # Check that the first line is not empty + if len(lines) > 0 and not lines[0].strip(): + lines = lines[1:] + else: + lines = [l for l in text.split('\n') if l.strip()] + + # Limit max number of text displayed + if max_lines: + if len(lines) > max_lines: + text = '\n'.join(lines[:max_lines]) + ' ...' + else: + text = '\n'.join(lines) + + text = text.replace('\n', '
    ') + if text_new_line and signature: + text = '
    ' + text + + template += BASE_TEMPLATE.format( + font_family=font_family, + size=text_size, + color=text_color, + main_text=text, + ) + + help_text = '' + if inspect_word: + if display_link: + help_text = ( + '' + 'Click anywhere in this tooltip for additional help' + ''.format( + font_size=text_size, + font_family=font_family, + ) + ) + else: + shortcut = self._get_inspect_shortcut() + if shortcut: + base_style = ( + f'background-color:{QStylePalette.COLOR_BACKGROUND_4};' + f'color:{QStylePalette.COLOR_TEXT_1};' + 'font-size:11px;' + ) + help_text = '' + # ( + # 'Press ' + # '[' + # '' + # '{0}] for aditional ' + # 'help'.format(shortcut, base_style) + # ) + + if help_text and inspect_word: + if display_link: + template += ( + '
    ' + '
    ' + f'' + ''.format(font_family=font_family, + size=text_size) + ) + help_text + '
    ' + else: + template += ( + '
    ' + '
    ' + '' + '' + help_text + '
    ' + ) + + return template + + def _format_signature(self, signatures, parameter=None, + max_width=_DEFAULT_MAX_WIDTH, + parameter_color=_PARAMETER_HIGHLIGHT_COLOR, + char_color=_CHAR_HIGHLIGHT_COLOR, + language=_DEFAULT_LANGUAGE): + """ + Create HTML template for signature. + + This template will include indent after the method name, a highlight + color for the active parameter and highlights for special chars. + + Special chars depend on the language. + """ + language = getattr(self, 'language', language).lower() + active_parameter_template = ( + '' + '{parameter}' + '' + ) + chars_template = ( + '{char}' + '' + ) + + def handle_sub(matchobj): + """ + Handle substitution of active parameter template. + + This ensures the correct highlight of the active parameter. + """ + match = matchobj.group(0) + new = match.replace(parameter, active_parameter_template) + return new + + if not isinstance(signatures, list): + signatures = [signatures] + + new_signatures = [] + for signature in signatures: + # Remove duplicate spaces + signature = ' '.join(signature.split()) + + # Replace initial spaces + signature = signature.replace('( ', '(') + + # Process signature template + if parameter and language == 'python': + # Escape all possible regex characters + # ( ) { } | [ ] . ^ $ * + + escape_regex_chars = ['|', '.', '^', '$', '*', '+'] + remove_regex_chars = ['(', ')', '{', '}', '[', ']'] + regex_parameter = parameter + for regex_char in escape_regex_chars + remove_regex_chars: + if regex_char in escape_regex_chars: + escape_char = r'\{char}'.format(char=regex_char) + regex_parameter = regex_parameter.replace(regex_char, + escape_char) + else: + regex_parameter = regex_parameter.replace(regex_char, + '') + parameter = parameter.replace(regex_char, '') + + pattern = (r'[\*|\(|\[|\s](' + regex_parameter + + r')[,|\)|\]|\s|=]') + + formatted_lines = [] + name = signature.split('(')[0] + indent = ' ' * (len(name) + 1) + rows = textwrap.wrap(signature, width=max_width, + subsequent_indent=indent) + for row in rows: + if parameter and language == 'python': + # Add template to highlight the active parameter + row = re.sub(pattern, handle_sub, row) + + row = row.replace(' ', ' ') + row = row.replace('span ', 'span ') + row = row.replace('{}', '{{}}') + + if language and language == 'python': + for char in ['(', ')', ',', '*', '**']: + new_char = chars_template.format(char=char) + row = row.replace(char, new_char) + + formatted_lines.append(row) + title_template = '
    '.join(formatted_lines) + + # Get current font properties + font = self.font() + font_size = font.pointSize() + font_family = font.family() + + # Format title to display active parameter + if parameter and language == 'python': + title = title_template.format( + font_size=font_size, + font_family=font_family, + color=parameter_color, + parameter=parameter, + ) + else: + title = title_template + new_signatures.append(title) + + return '
    '.join(new_signatures) + + def _check_signature_and_format(self, signature_or_text, parameter=None, + inspect_word=None, + max_width=_DEFAULT_MAX_WIDTH, + language=_DEFAULT_LANGUAGE): + """ + LSP hints might provide docstrings instead of signatures. + + This method will check for multiple signatures (dict, type etc...) and + format the text accordingly. + """ + open_func_char = '' + has_signature = False + has_multisignature = False + language = getattr(self, 'language', language).lower() + signature_or_text = signature_or_text.replace('\\*', '*') + + # Remove special symbols that could itefere with ''.format + signature_or_text = signature_or_text.replace('{', '{') + signature_or_text = signature_or_text.replace('}', '}') + + # Remove 'ufunc' signature if needed. See spyder-ide/spyder#11821 + lines = [line for line in signature_or_text.split('\n') + if 'ufunc' not in line] + signature_or_text = '\n'.join(lines) + + if language == 'python': + open_func_char = '(' + has_multisignature = False + + if inspect_word: + has_signature = signature_or_text.startswith(inspect_word) + else: + idx = signature_or_text.find(open_func_char) + inspect_word = signature_or_text[:idx] + has_signature = True + + if has_signature: + name_plus_char = inspect_word + open_func_char + + all_lines = [] + for line in lines: + if (line.startswith(name_plus_char) + and line.count(name_plus_char) > 1): + sublines = line.split(name_plus_char) + sublines = [name_plus_char + l for l in sublines] + sublines = [l.strip() for l in sublines] + else: + sublines = [line] + + all_lines = all_lines + sublines + + lines = all_lines + count = 0 + for line in lines: + if line.startswith(name_plus_char): + count += 1 + + # Signature type + has_signature = count == 1 + has_multisignature = count > 1 and len(lines) > 1 + + if has_signature and not has_multisignature: + for i, line in enumerate(lines): + if line.strip() == '': + break + + if i == 0: + signature = lines[0] + extra_text = None + else: + signature = '\n'.join(lines[:i]) + extra_text = '\n'.join(lines[i:]) + + if signature: + new_signature = self._format_signature( + signatures=signature, + parameter=parameter, + max_width=max_width + ) + elif has_multisignature: + signature = signature_or_text.replace(name_plus_char, + '
    ' + name_plus_char) + signature = signature[4:] # Remove the first line break + signature = signature.replace('\n', ' ') + signature = signature.replace(r'\\*', '*') + signature = signature.replace(r'\*', '*') + signature = signature.replace('
    ', '\n') + signatures = signature.split('\n') + signatures = [sig for sig in signatures if sig] # Remove empty + new_signature = self._format_signature( + signatures=signatures, + parameter=parameter, + max_width=max_width + ) + extra_text = None + else: + new_signature = None + extra_text = signature_or_text + + return new_signature, extra_text, inspect_word + + def show_calltip(self, signature, parameter=None, documentation=None, + language=_DEFAULT_LANGUAGE, max_lines=_DEFAULT_MAX_LINES, + max_width=_DEFAULT_MAX_WIDTH, text_new_line=True): + """ + Show calltip. + + Calltips look like tooltips but will not disappear if mouse hovers + them. They are useful for displaying signature information on methods + and functions. + """ + # Find position of calltip + point = self._calculate_position() + signature = signature.strip() + inspect_word = None + language = getattr(self, 'language', language).lower() + if language == 'python' and signature: + inspect_word = signature.split('(')[0] + # Check if documentation is better than signature, sometimes + # signature has \n stripped for functions like print, type etc + check_doc = ' ' + if documentation: + check_doc.join(documentation.split()).replace('\\*', '*') + check_sig = ' '.join(signature.split()) + if check_doc == check_sig: + signature = documentation + documentation = '' + + # Remove duplicate signature inside documentation + if documentation: + documentation = documentation.replace('\\*', '*') + if signature.strip(): + documentation = documentation.replace(signature + '\n', '') + + # Format + res = self._check_signature_and_format(signature, parameter, + inspect_word=inspect_word, + language=language, + max_width=max_width) + new_signature, text, inspect_word = res + text = self._format_text( + signature=new_signature, + inspect_word=inspect_word, + display_link=False, + text=documentation, + max_lines=max_lines, + max_width=max_width, + text_new_line=text_new_line + ) + + self._update_stylesheet(self.calltip_widget) + + # Show calltip + self.calltip_widget.show_tip(point, text, []) + self.calltip_widget.show() + + def show_tooltip(self, title=None, signature=None, text=None, + inspect_word=None, title_color=_DEFAULT_TITLE_COLOR, + at_line=None, at_point=None, display_link=False, + max_lines=_DEFAULT_MAX_LINES, + max_width=_DEFAULT_MAX_WIDTH, + cursor=None, + with_html_format=False, + text_new_line=True, + completion_doc=None): + """Show tooltip.""" + # Find position of calltip + point = self._calculate_position( + at_line=at_line, + at_point=at_point, + ) + # Format text + tiptext = self._format_text( + title=title, + signature=signature, + text=text, + title_color=title_color, + inspect_word=inspect_word, + display_link=display_link, + max_lines=max_lines, + max_width=max_width, + with_html_format=with_html_format, + text_new_line=text_new_line + ) + + self._update_stylesheet(self.tooltip_widget) + + # Display tooltip + self.tooltip_widget.show_tip(point, tiptext, cursor=cursor, + completion_doc=completion_doc) + + def show_hint(self, text, inspect_word, at_point, + max_lines=_DEFAULT_MAX_HINT_LINES, + max_width=_DEFAULT_MAX_HINT_WIDTH, + text_new_line=True, completion_doc=None): + """Show code hint and crop text as needed.""" + res = self._check_signature_and_format(text, max_width=max_width, + inspect_word=inspect_word) + html_signature, extra_text, _ = res + point = self.get_word_start_pos(at_point) + + # Only display hover hint if there is documentation + if extra_text is not None: + # This is needed to get hover hints + cursor = self.cursorForPosition(at_point) + cursor.movePosition(QTextCursor.StartOfWord, + QTextCursor.MoveAnchor) + self._last_hover_cursor = cursor + + self.show_tooltip(signature=html_signature, text=extra_text, + at_point=point, inspect_word=inspect_word, + display_link=True, max_lines=max_lines, + max_width=max_width, cursor=cursor, + text_new_line=text_new_line, + completion_doc=completion_doc) + + def hide_tooltip(self): + """ + Hide the tooltip widget. + + The tooltip widget is a special QLabel that looks like a tooltip, + this method is here so it can be hidden as necessary. For example, + when the user leaves the Linenumber area when hovering over lint + warnings and errors. + """ + self._last_hover_cursor = None + self._last_hover_word = None + self._last_point = None + self.tooltip_widget.hide() + + # ----- Required methods for the LSP + def document_did_change(self, text=None): + pass + + #------EOL characters + def set_eol_chars(self, text=None, eol_chars=None): + """ + Set widget end-of-line (EOL) characters. + + Parameters + ---------- + text: str + Text to detect EOL characters from. + eol_chars: str + EOL characters to set. + + Notes + ----- + If `text` is passed, then `eol_chars` has no effect. + """ + if text is not None: + detected_eol_chars = sourcecode.get_eol_chars(text) + is_document_modified = ( + detected_eol_chars is not None and self.eol_chars is not None + ) + self.eol_chars = detected_eol_chars + elif eol_chars is not None: + is_document_modified = eol_chars != self.eol_chars + self.eol_chars = eol_chars + + if is_document_modified: + self.document().setModified(True) + if self.sig_eol_chars_changed is not None: + self.sig_eol_chars_changed.emit(eol_chars) + + def get_line_separator(self): + """Return line separator based on current EOL mode""" + if self.eol_chars is not None: + return self.eol_chars + else: + return os.linesep + + def get_text_with_eol(self): + """ + Same as 'toPlainText', replacing '\n' by correct end-of-line + characters. + """ + text = self.toPlainText() + linesep = self.get_line_separator() + for symbol in EOL_SYMBOLS: + text = text.replace(symbol, linesep) + return text + + #------Positions, coordinates (cursor, EOF, ...) + def get_position(self, subject): + """Get offset in character for the given subject from the start of + text edit area""" + cursor = self.textCursor() + if subject == 'cursor': + pass + elif subject == 'sol': + cursor.movePosition(QTextCursor.StartOfBlock) + elif subject == 'eol': + cursor.movePosition(QTextCursor.EndOfBlock) + elif subject == 'eof': + cursor.movePosition(QTextCursor.End) + elif subject == 'sof': + cursor.movePosition(QTextCursor.Start) + else: + # Assuming that input argument was already a position + return subject + return cursor.position() + + def get_coordinates(self, position): + position = self.get_position(position) + cursor = self.textCursor() + cursor.setPosition(position) + point = self.cursorRect(cursor).center() + return point.x(), point.y() + + def _is_point_inside_word_rect(self, point): + """ + Check if the mouse is within the rect of the cursor current word. + """ + cursor = self.cursorForPosition(point) + cursor.movePosition(QTextCursor.StartOfWord, QTextCursor.MoveAnchor) + start_rect = self.cursorRect(cursor) + cursor.movePosition(QTextCursor.EndOfWord, QTextCursor.MoveAnchor) + end_rect = self.cursorRect(cursor) + bounding_rect = start_rect.united(end_rect) + return bounding_rect.contains(point) + + def get_word_start_pos(self, position): + """ + Find start position (lower bottom) of a word being hovered by mouse. + """ + cursor = self.cursorForPosition(position) + cursor.movePosition(QTextCursor.StartOfWord, QTextCursor.MoveAnchor) + rect = self.cursorRect(cursor) + pos = QPoint(rect.left() + 4, rect.top()) + return pos + + def get_last_hover_word(self): + """Return the last (or active) hover word.""" + return self._last_hover_word + + def get_last_hover_cursor(self): + """Return the last (or active) hover cursor.""" + return self._last_hover_cursor + + def get_cursor_line_column(self, cursor=None): + """ + Return `cursor` (line, column) numbers. + + If no `cursor` is provided, use the current text cursor. + """ + if cursor is None: + cursor = self.textCursor() + + return cursor.blockNumber(), cursor.columnNumber() + + def get_cursor_line_number(self): + """Return cursor line number""" + return self.textCursor().blockNumber()+1 + + def get_position_line_number(self, line, col): + """Get position offset from (line, col) coordinates.""" + block = self.document().findBlockByNumber(line) + cursor = QTextCursor(block) + cursor.movePosition(QTextCursor.StartOfBlock) + cursor.movePosition(QTextCursor.Right, QTextCursor.KeepAnchor, + n=col + 1) + return cursor.position() + + def set_cursor_position(self, position): + """Set cursor position""" + position = self.get_position(position) + cursor = self.textCursor() + cursor.setPosition(position) + self.setTextCursor(cursor) + self.ensureCursorVisible() + + def move_cursor(self, chars=0): + """Move cursor to left or right (unit: characters)""" + direction = QTextCursor.Right if chars > 0 else QTextCursor.Left + for _i in range(abs(chars)): + self.moveCursor(direction, QTextCursor.MoveAnchor) + + def is_cursor_on_first_line(self): + """Return True if cursor is on the first line""" + cursor = self.textCursor() + cursor.movePosition(QTextCursor.StartOfBlock) + return cursor.atStart() + + def is_cursor_on_last_line(self): + """Return True if cursor is on the last line""" + cursor = self.textCursor() + cursor.movePosition(QTextCursor.EndOfBlock) + return cursor.atEnd() + + def is_cursor_at_end(self): + """Return True if cursor is at the end of the text""" + return self.textCursor().atEnd() + + def is_cursor_before(self, position, char_offset=0): + """Return True if cursor is before *position*""" + position = self.get_position(position) + char_offset + cursor = self.textCursor() + cursor.movePosition(QTextCursor.End) + if position < cursor.position(): + cursor.setPosition(position) + return self.textCursor() < cursor + + def __move_cursor_anchor(self, what, direction, move_mode): + assert what in ('character', 'word', 'line') + if what == 'character': + if direction == 'left': + self.moveCursor(QTextCursor.PreviousCharacter, move_mode) + elif direction == 'right': + self.moveCursor(QTextCursor.NextCharacter, move_mode) + elif what == 'word': + if direction == 'left': + self.moveCursor(QTextCursor.PreviousWord, move_mode) + elif direction == 'right': + self.moveCursor(QTextCursor.NextWord, move_mode) + elif what == 'line': + if direction == 'down': + self.moveCursor(QTextCursor.NextBlock, move_mode) + elif direction == 'up': + self.moveCursor(QTextCursor.PreviousBlock, move_mode) + + def move_cursor_to_next(self, what='word', direction='left'): + """ + Move cursor to next *what* ('word' or 'character') + toward *direction* ('left' or 'right') + """ + self.__move_cursor_anchor(what, direction, QTextCursor.MoveAnchor) + + #------Selection + def extend_selection_to_next(self, what='word', direction='left'): + """ + Extend selection to next *what* ('word' or 'character') + toward *direction* ('left' or 'right') + """ + self.__move_cursor_anchor(what, direction, QTextCursor.KeepAnchor) + + #------Text: get, set, ... + + def _select_text(self, position_from, position_to): + """Select text and return cursor.""" + position_from = self.get_position(position_from) + position_to = self.get_position(position_to) + cursor = self.textCursor() + cursor.setPosition(position_from) + cursor.setPosition(position_to, QTextCursor.KeepAnchor) + return cursor + + def get_text_line(self, line_nb): + """Return text line at line number *line_nb*""" + block = self.document().findBlockByNumber(line_nb) + cursor = QTextCursor(block) + cursor.movePosition(QTextCursor.StartOfBlock) + cursor.movePosition(QTextCursor.EndOfBlock, mode=QTextCursor.KeepAnchor) + return to_text_string(cursor.selectedText()) + + def get_text_region(self, start_line, end_line): + """Return text lines spanned from *start_line* to *end_line*.""" + start_block = self.document().findBlockByNumber(start_line) + end_block = self.document().findBlockByNumber(end_line) + + start_cursor = QTextCursor(start_block) + start_cursor.movePosition(QTextCursor.StartOfBlock) + end_cursor = QTextCursor(end_block) + end_cursor.movePosition(QTextCursor.EndOfBlock) + end_position = end_cursor.position() + start_cursor.setPosition(end_position, mode=QTextCursor.KeepAnchor) + return self.get_selected_text(start_cursor) + + def get_text(self, position_from, position_to, remove_newlines=True): + """Returns text between *position_from* and *position_to*. + + Positions may be integers or 'sol', 'eol', 'sof', 'eof' or 'cursor'. + + Unless position_from='sof' and position_to='eof' any trailing newlines + in the string are removed. This was added as a workaround for + spyder-ide/spyder#1546 and later caused spyder-ide/spyder#14374. + The behaviour can be overridden by setting the optional parameter + *remove_newlines* to False. + + TODO: Evaluate if this is still a problem and if the workaround can + be moved closer to where the problem occurs. + """ + cursor = self._select_text(position_from, position_to) + text = to_text_string(cursor.selectedText()) + if remove_newlines: + remove_newlines = position_from != 'sof' or position_to != 'eof' + if text and remove_newlines: + while text and text[-1] in EOL_SYMBOLS: + text = text[:-1] + return text + + def get_character(self, position, offset=0): + """Return character at *position* with the given offset.""" + position = self.get_position(position) + offset + cursor = self.textCursor() + cursor.movePosition(QTextCursor.End) + if position < cursor.position(): + cursor.setPosition(position) + cursor.movePosition(QTextCursor.Right, + QTextCursor.KeepAnchor) + return to_text_string(cursor.selectedText()) + else: + return '' + + def insert_text(self, text, will_insert_text=True): + """Insert text at cursor position""" + if not self.isReadOnly(): + if will_insert_text and self.sig_will_insert_text is not None: + self.sig_will_insert_text.emit(text) + self.textCursor().insertText(text) + if self.sig_text_was_inserted is not None: + self.sig_text_was_inserted.emit() + + def replace_text(self, position_from, position_to, text): + cursor = self._select_text(position_from, position_to) + if self.sig_will_remove_selection is not None: + start, end = self.get_selection_start_end(cursor) + self.sig_will_remove_selection.emit(start, end) + cursor.removeSelectedText() + if self.sig_will_insert_text is not None: + self.sig_will_insert_text.emit(text) + cursor.insertText(text) + if self.sig_text_was_inserted is not None: + self.sig_text_was_inserted.emit() + + def remove_text(self, position_from, position_to): + cursor = self._select_text(position_from, position_to) + if self.sig_will_remove_selection is not None: + start, end = self.get_selection_start_end(cursor) + self.sig_will_remove_selection.emit(start, end) + cursor.removeSelectedText() + + def get_current_object(self): + """ + Return current object under cursor. + + Get the text of the current word plus all the characters + to the left until a space is found. Used to get text to inspect + for Help of elements following dot notation for example + np.linalg.norm + """ + cursor = self.textCursor() + cursor_pos = cursor.position() + current_word = self.get_current_word(help_req=True) + + # Get max position to the left of cursor until space or no more + # characters are left + cursor.movePosition(QTextCursor.PreviousCharacter) + while self.get_character(cursor.position()).strip(): + cursor.movePosition(QTextCursor.PreviousCharacter) + if cursor.atBlockStart(): + break + cursor_pos_left = cursor.position() + + # Get max position to the right of cursor until space or no more + # characters are left + cursor.setPosition(cursor_pos) + while self.get_character(cursor.position()).strip(): + cursor.movePosition(QTextCursor.NextCharacter) + if cursor.atBlockEnd(): + break + cursor_pos_right = cursor.position() + + # Get text of the object under the cursor + current_text = self.get_text( + cursor_pos_left, cursor_pos_right).strip() + current_object = current_word + + if current_text and current_word is not None: + if current_word != current_text and current_word in current_text: + current_object = ( + current_text.split(current_word)[0] + current_word) + + return current_object + + def get_current_word_and_position(self, completion=False, help_req=False, + valid_python_variable=True): + """ + Return current word, i.e. word at cursor position, and the start + position. + """ + cursor = self.textCursor() + cursor_pos = cursor.position() + + if cursor.hasSelection(): + # Removes the selection and moves the cursor to the left side + # of the selection: this is required to be able to properly + # select the whole word under cursor (otherwise, the same word is + # not selected when the cursor is at the right side of it): + cursor.setPosition(min([cursor.selectionStart(), + cursor.selectionEnd()])) + else: + # Checks if the first character to the right is a white space + # and if not, moves the cursor one word to the left (otherwise, + # if the character to the left do not match the "word regexp" + # (see below), the word to the left of the cursor won't be + # selected), but only if the first character to the left is not a + # white space too. + def is_space(move): + curs = self.textCursor() + curs.movePosition(move, QTextCursor.KeepAnchor) + return not to_text_string(curs.selectedText()).strip() + + def is_special_character(move): + """Check if a character is a non-letter including numbers.""" + curs = self.textCursor() + curs.movePosition(move, QTextCursor.KeepAnchor) + text_cursor = to_text_string(curs.selectedText()).strip() + return len( + re.findall(r'([^\d\W]\w*)', text_cursor, re.UNICODE)) == 0 + + if help_req: + if is_special_character(QTextCursor.PreviousCharacter): + cursor.movePosition(QTextCursor.NextCharacter) + elif is_special_character(QTextCursor.NextCharacter): + cursor.movePosition(QTextCursor.PreviousCharacter) + elif not completion: + if is_space(QTextCursor.NextCharacter): + if is_space(QTextCursor.PreviousCharacter): + return + cursor.movePosition(QTextCursor.WordLeft) + else: + if is_space(QTextCursor.PreviousCharacter): + return + if (is_special_character(QTextCursor.NextCharacter)): + cursor.movePosition(QTextCursor.WordLeft) + + cursor.select(QTextCursor.WordUnderCursor) + text = to_text_string(cursor.selectedText()) + startpos = cursor.selectionStart() + + # Find a valid Python variable name + if valid_python_variable: + match = re.findall(r'([^\d\W]\w*)', text, re.UNICODE) + if not match: + # This is assumed in several places of our codebase, + # so please don't change this return! + return None + else: + text = match[0] + + if completion: + text = text[:cursor_pos - startpos] + + return text, startpos + + def get_current_word(self, completion=False, help_req=False, + valid_python_variable=True): + """Return current word, i.e. word at cursor position.""" + ret = self.get_current_word_and_position( + completion=completion, + help_req=help_req, + valid_python_variable=valid_python_variable + ) + + if ret is not None: + return ret[0] + + def get_hover_word(self): + """Return the last hover word that requested a hover hint.""" + return self._last_hover_word + + def get_current_line(self): + """Return current line's text.""" + cursor = self.textCursor() + cursor.select(QTextCursor.BlockUnderCursor) + return to_text_string(cursor.selectedText()) + + def get_current_line_to_cursor(self): + """Return text from prompt to cursor.""" + return self.get_text(self.current_prompt_pos, 'cursor') + + def get_line_number_at(self, coordinates): + """Return line number at *coordinates* (QPoint).""" + cursor = self.cursorForPosition(coordinates) + return cursor.blockNumber() + 1 + + def get_line_at(self, coordinates): + """Return line at *coordinates* (QPoint).""" + cursor = self.cursorForPosition(coordinates) + cursor.select(QTextCursor.BlockUnderCursor) + return to_text_string(cursor.selectedText()).replace(u'\u2029', '') + + def get_word_at(self, coordinates): + """Return word at *coordinates* (QPoint).""" + cursor = self.cursorForPosition(coordinates) + cursor.select(QTextCursor.WordUnderCursor) + if self._is_point_inside_word_rect(coordinates): + word = to_text_string(cursor.selectedText()) + else: + word = '' + + return word + + def get_line_indentation(self, text): + """Get indentation for given line.""" + text = text.replace("\t", " "*self.tab_stop_width_spaces) + return len(text)-len(text.lstrip()) + + def get_block_indentation(self, block_nb): + """Return line indentation (character number).""" + text = to_text_string(self.document().findBlockByNumber(block_nb).text()) + return self.get_line_indentation(text) + + def get_selection_bounds(self, cursor=None): + """Return selection bounds (block numbers).""" + if cursor is None: + cursor = self.textCursor() + start, end = cursor.selectionStart(), cursor.selectionEnd() + block_start = self.document().findBlock(start) + block_end = self.document().findBlock(end) + return sorted([block_start.blockNumber(), block_end.blockNumber()]) + + def get_selection_start_end(self, cursor=None): + """Return selection start and end (line, column) positions.""" + if cursor is None: + cursor = self.textCursor() + start, end = cursor.selectionStart(), cursor.selectionEnd() + start_cursor = QTextCursor(cursor) + start_cursor.setPosition(start) + start_position = self.get_cursor_line_column(start_cursor) + end_cursor = QTextCursor(cursor) + end_cursor.setPosition(end) + end_position = self.get_cursor_line_column(end_cursor) + return start_position, end_position + + #------Text selection + def has_selected_text(self): + """Returns True if some text is selected.""" + return bool(to_text_string(self.textCursor().selectedText())) + + def get_selected_text(self, cursor=None): + """ + Return text selected by current text cursor, converted in unicode. + + Replace the unicode line separator character \u2029 by + the line separator characters returned by get_line_separator + """ + if cursor is None: + cursor = self.textCursor() + return to_text_string(cursor.selectedText()).replace(u"\u2029", + self.get_line_separator()) + + def remove_selected_text(self): + """Delete selected text.""" + self.textCursor().removeSelectedText() + # The next three lines are a workaround for a quirk of + # QTextEdit on Linux with Qt < 5.15, MacOs and Windows. + # See spyder-ide/spyder#12663 and + # https://bugreports.qt.io/browse/QTBUG-35861 + if (parse_version(QT_VERSION) < parse_version('5.15') + or os.name == 'nt' or sys.platform == 'darwin'): + cursor = self.textCursor() + cursor.setPosition(cursor.position()) + self.setTextCursor(cursor) + + def replace(self, text, pattern=None): + """Replace selected text by *text*. + + If *pattern* is not None, replacing selected text using regular + expression text substitution.""" + cursor = self.textCursor() + cursor.beginEditBlock() + if pattern is not None: + seltxt = to_text_string(cursor.selectedText()) + if self.sig_will_remove_selection is not None: + start, end = self.get_selection_start_end(cursor) + self.sig_will_remove_selection.emit(start, end) + cursor.removeSelectedText() + if pattern is not None: + text = re.sub(to_text_string(pattern), + to_text_string(text), to_text_string(seltxt)) + if self.sig_will_insert_text is not None: + self.sig_will_insert_text.emit(text) + cursor.insertText(text) + if self.sig_text_was_inserted is not None: + self.sig_text_was_inserted.emit() + cursor.endEditBlock() + + + #------Find/replace + def find_multiline_pattern(self, regexp, cursor, findflag): + """Reimplement QTextDocument's find method. + + Add support for *multiline* regular expressions.""" + pattern = to_text_string(regexp.pattern()) + text = to_text_string(self.toPlainText()) + try: + regobj = re.compile(pattern) + except sre_constants.error: + return + if findflag & QTextDocument.FindBackward: + # Find backward + offset = min([cursor.selectionEnd(), cursor.selectionStart()]) + text = text[:offset] + matches = [_m for _m in regobj.finditer(text, 0, offset)] + if matches: + match = matches[-1] + else: + return + else: + # Find forward + offset = max([cursor.selectionEnd(), cursor.selectionStart()]) + match = regobj.search(text, offset) + if match: + pos1, pos2 = sh.get_span(match) + fcursor = self.textCursor() + fcursor.setPosition(pos1) + fcursor.setPosition(pos2, QTextCursor.KeepAnchor) + return fcursor + + def find_text(self, text, changed=True, forward=True, case=False, + word=False, regexp=False): + """Find text.""" + cursor = self.textCursor() + findflag = QTextDocument.FindFlag() + + # Get visible region to center cursor in case it's necessary. + if getattr(self, 'get_visible_block_numbers', False): + current_visible_region = self.get_visible_block_numbers() + else: + current_visible_region = None + + if not forward: + findflag = findflag | QTextDocument.FindBackward + + if case: + findflag = findflag | QTextDocument.FindCaseSensitively + + moves = [QTextCursor.NoMove] + if forward: + moves += [QTextCursor.NextWord, QTextCursor.Start] + if changed: + if to_text_string(cursor.selectedText()): + new_position = min([cursor.selectionStart(), + cursor.selectionEnd()]) + cursor.setPosition(new_position) + else: + cursor.movePosition(QTextCursor.PreviousWord) + else: + moves += [QTextCursor.End] + + if regexp: + text = to_text_string(text) + else: + text = re.escape(to_text_string(text)) + + pattern = QRegularExpression(u"\\b{}\\b".format(text) if word else + text) + if case: + pattern.setPatternOptions(QRegularExpression.CaseInsensitiveOption) + + for move in moves: + cursor.movePosition(move) + if regexp and '\\n' in text: + # Multiline regular expression + found_cursor = self.find_multiline_pattern(pattern, cursor, + findflag) + else: + # Single line find: using the QTextDocument's find function, + # probably much more efficient than ours + found_cursor = self.document().find(pattern, cursor, findflag) + if found_cursor is not None and not found_cursor.isNull(): + self.setTextCursor(found_cursor) + + # Center cursor if we move out of the visible region. + if current_visible_region is not None: + found_visible_region = self.get_visible_block_numbers() + if current_visible_region != found_visible_region: + current_visible_region = found_visible_region + self.centerCursor() + + return True + + return False + + def is_editor(self): + """Needs to be overloaded in the codeeditor where it will be True""" + return False + + def get_number_matches(self, pattern, source_text='', case=False, + regexp=False, word=False): + """Get the number of matches for the searched text.""" + pattern = to_text_string(pattern) + if not pattern: + return 0 + + if not regexp: + pattern = re.escape(pattern) + + if not source_text: + source_text = to_text_string(self.toPlainText()) + + if word: # match whole words only + pattern = r'\b{pattern}\b'.format(pattern=pattern) + try: + re_flags = re.MULTILINE if case else re.IGNORECASE | re.MULTILINE + regobj = re.compile(pattern, flags=re_flags) + except sre_constants.error: + return None + + number_matches = 0 + for match in regobj.finditer(source_text): + number_matches += 1 + + return number_matches + + def get_match_number(self, pattern, case=False, regexp=False, word=False): + """Get number of the match for the searched text.""" + position = self.textCursor().position() + source_text = self.get_text(position_from='sof', position_to=position) + match_number = self.get_number_matches(pattern, + source_text=source_text, + case=case, regexp=regexp, + word=word) + return match_number + + # --- Array builder helper / See 'spyder/widgets/arraybuilder.py' + def enter_array_inline(self): + """Enter array builder inline mode.""" + self._enter_array(True) + + def enter_array_table(self): + """Enter array builder table mode.""" + self._enter_array(False) + + def _enter_array(self, inline): + """Enter array builder mode.""" + offset = self.get_position('cursor') - self.get_position('sol') + rect = self.cursorRect() + dlg = ArrayBuilderDialog(self, inline, offset) + + # TODO: adapt to font size + x = rect.left() + x = int(x - 14) + y = rect.top() + (rect.bottom() - rect.top())/2 + y = int(y - dlg.height()/2 - 3) + + pos = QPoint(x, y) + pos = self.calculate_real_position(pos) + dlg.move(self.mapToGlobal(pos)) + + # called from editor + if self.is_editor(): + python_like_check = self.is_python_like() + suffix = '\n' + # called from a console + else: + python_like_check = True + suffix = '' + + if python_like_check and dlg.exec_(): + text = dlg.text() + suffix + if text != '': + cursor = self.textCursor() + cursor.beginEditBlock() + if self.sig_will_insert_text is not None: + self.sig_will_insert_text.emit(text) + cursor.insertText(text) + if self.sig_text_was_inserted is not None: + self.sig_text_was_inserted.emit() + cursor.endEditBlock() + + +class TracebackLinksMixin(object): + """ """ + QT_CLASS = None + + # This signal emits a parsed error traceback text so we can then + # request opening the file that traceback comes from in the Editor. + sig_go_to_error_requested = None + + def __init__(self): + self.__cursor_changed = False + self.setMouseTracking(True) + + #------Mouse events + def mouseReleaseEvent(self, event): + """Go to error""" + self.QT_CLASS.mouseReleaseEvent(self, event) + text = self.get_line_at(event.pos()) + if get_error_match(text) and not self.has_selected_text(): + if self.sig_go_to_error_requested is not None: + self.sig_go_to_error_requested.emit(text) + + def mouseMoveEvent(self, event): + """Show Pointing Hand Cursor on error messages""" + text = self.get_line_at(event.pos()) + if get_error_match(text): + if not self.__cursor_changed: + QApplication.setOverrideCursor(QCursor(Qt.PointingHandCursor)) + self.__cursor_changed = True + event.accept() + return + if self.__cursor_changed: + QApplication.restoreOverrideCursor() + self.__cursor_changed = False + self.QT_CLASS.mouseMoveEvent(self, event) + + def leaveEvent(self, event): + """If cursor has not been restored yet, do it now""" + if self.__cursor_changed: + QApplication.restoreOverrideCursor() + self.__cursor_changed = False + self.QT_CLASS.leaveEvent(self, event) + + +class GetHelpMixin(object): + + def __init__(self): + self.help_enabled = False + + def set_help_enabled(self, state): + self.help_enabled = state + + def inspect_current_object(self): + current_object = self.get_current_object() + if current_object is not None: + self.show_object_info(current_object, force=True) + + def show_object_info(self, text, call=False, force=False): + """Show signature calltip and/or docstring in the Help plugin""" + text = to_text_string(text) + + # Show docstring + help_enabled = self.help_enabled or force + if help_enabled: + doc = { + 'name': text, + 'ignore_unknown': False, + } + self.sig_help_requested.emit(doc) + + # Show calltip + if call and getattr(self, 'calltips', None): + # Display argument list if this is a function call + iscallable = self.iscallable(text) + if iscallable is not None: + if iscallable: + arglist = self.get_arglist(text) + name = text.split('.')[-1] + argspec = signature = '' + if isinstance(arglist, bool): + arglist = [] + if arglist: + argspec = '(' + ''.join(arglist) + ')' + else: + doc = self.get__doc__(text) + if doc is not None: + # This covers cases like np.abs, whose docstring is + # the same as np.absolute and because of that a + # proper signature can't be obtained correctly + argspec = getargspecfromtext(doc) + if not argspec: + signature = getsignaturefromtext(doc, name) + if argspec or signature: + if argspec: + tiptext = name + argspec + else: + tiptext = signature + # TODO: Select language and pass it to call + self.show_calltip(tiptext) + + def get_last_obj(self, last=False): + """ + Return the last valid object on the current line + """ + return getobj(self.get_current_line_to_cursor(), last=last) + + +class SaveHistoryMixin(object): + + INITHISTORY = None + SEPARATOR = None + HISTORY_FILENAMES = [] + + sig_append_to_history_requested = None + + def __init__(self, history_filename=''): + self.history_filename = history_filename + self.create_history_filename() + + def create_history_filename(self): + """Create history_filename with INITHISTORY if it doesn't exist.""" + if self.history_filename and not osp.isfile(self.history_filename): + try: + encoding.writelines(self.INITHISTORY, self.history_filename) + except EnvironmentError: + pass + + def add_to_history(self, command): + """Add command to history""" + command = to_text_string(command) + if command in ['', '\n'] or command.startswith('Traceback'): + return + if command.endswith('\n'): + command = command[:-1] + self.histidx = None + if len(self.history) > 0 and self.history[-1] == command: + return + self.history.append(command) + text = os.linesep + command + + # When the first entry will be written in history file, + # the separator will be append first: + if self.history_filename not in self.HISTORY_FILENAMES: + self.HISTORY_FILENAMES.append(self.history_filename) + text = self.SEPARATOR + text + # Needed to prevent errors when writing history to disk + # See spyder-ide/spyder#6431. + try: + encoding.write(text, self.history_filename, mode='ab') + except EnvironmentError: + pass + if self.sig_append_to_history_requested is not None: + self.sig_append_to_history_requested.emit( + self.history_filename, text) + + +class BrowseHistory(object): + + def __init__(self): + self.history = [] + self.histidx = None + self.hist_wholeline = False + + def browse_history(self, line, cursor_pos, backward): + """ + Browse history. + + Return the new text and wherever the cursor should move. + """ + if cursor_pos < len(line) and self.hist_wholeline: + self.hist_wholeline = False + tocursor = line[:cursor_pos] + text, self.histidx = self.find_in_history(tocursor, self.histidx, + backward) + if text is not None: + text = text.strip() + if self.hist_wholeline: + return text, True + else: + return tocursor + text, False + return None, False + + def find_in_history(self, tocursor, start_idx, backward): + """Find text 'tocursor' in history, from index 'start_idx'""" + if start_idx is None: + start_idx = len(self.history) + # Finding text in history + step = -1 if backward else 1 + idx = start_idx + if len(tocursor) == 0 or self.hist_wholeline: + idx += step + if idx >= len(self.history) or len(self.history) == 0: + return "", len(self.history) + elif idx < 0: + idx = 0 + self.hist_wholeline = True + return self.history[idx], idx + else: + for index in range(len(self.history)): + idx = (start_idx+step*(index+1)) % len(self.history) + entry = self.history[idx] + if entry.startswith(tocursor): + return entry[len(tocursor):], idx + else: + return None, start_idx + + def reset_search_pos(self): + """Reset the position from which to search the history""" + self.histidx = None + + +class BrowseHistoryMixin(BrowseHistory): + + def clear_line(self): + """Clear current line (without clearing console prompt)""" + self.remove_text(self.current_prompt_pos, 'eof') + + def browse_history(self, backward): + """Browse history""" + line = self.get_text(self.current_prompt_pos, 'eof') + old_pos = self.get_position('cursor') + cursor_pos = self.get_position('cursor') - self.current_prompt_pos + if cursor_pos < 0: + cursor_pos = 0 + self.set_cursor_position(self.current_prompt_pos) + text, move_cursor = super(BrowseHistoryMixin, self).browse_history( + line, cursor_pos, backward) + if text is not None: + self.clear_line() + self.insert_text(text) + if not move_cursor: + self.set_cursor_position(old_pos) diff --git a/spyder/widgets/onecolumntree.py b/spyder/widgets/onecolumntree.py index 240a5133ed1..7804903bd7d 100644 --- a/spyder/widgets/onecolumntree.py +++ b/spyder/widgets/onecolumntree.py @@ -1,307 +1,307 @@ -# -*- coding: utf-8 -*- -# -# Copyright © Spyder Project Contributors -# Licensed under the terms of the MIT License -# (see spyder/__init__.py for details) - -# Third party imports -from qtpy import PYQT5 -from qtpy.QtCore import Qt, Slot -from qtpy.QtWidgets import QAbstractItemView, QHeaderView, QTreeWidget - -# Local imports -from spyder.api.widgets.mixins import SpyderWidgetMixin -from spyder.config.base import _ -from spyder.utils.icon_manager import ima -from spyder.utils.qthelpers import get_item_user_text - - -class OneColumnTreeActions: - CollapseAllAction = "collapse_all_action" - ExpandAllAction = "expand_all_action" - RestoreAction = "restore_action" - CollapseSelectionAction = "collapse_selection_action" - ExpandSelectionAction = "expand_selection_action" - - -class OneColumnTreeContextMenuSections: - Global = "global_section" - Restore = "restore_section" - Section = "section_section" - History = "history_section" - - -class OneColumnTree(QTreeWidget, SpyderWidgetMixin): - """ - One-column tree widget with context menu. - """ - - def __init__(self, parent): - if PYQT5: - super().__init__(parent, class_parent=parent) - else: - QTreeWidget.__init__(self, parent) - SpyderWidgetMixin.__init__(self, class_parent=parent) - - self.__expanded_state = None - - # Widget setup - self.setItemsExpandable(True) - self.setColumnCount(1) - - # Setup context menu - self.collapse_all_action = None - self.collapse_selection_action = None - self.expand_all_action = None - self.expand_selection_action = None - self.setup() - self.common_actions = self.setup_common_actions() - - # Signals - self.itemActivated.connect(self.activated) - self.itemClicked.connect(self.clicked) - self.itemSelectionChanged.connect(self.item_selection_changed) - - # To use mouseMoveEvent - self.setMouseTracking(True) - - # Use horizontal scrollbar when needed - self.setHorizontalScrollMode(QAbstractItemView.ScrollPerPixel) - self.header().setSectionResizeMode(0, QHeaderView.ResizeToContents) - self.header().setStretchLastSection(False) - - self.item_selection_changed() - - # ---- SpyderWidgetMixin API - # ------------------------------------------------------------------------- - def setup(self): - self.menu = self.create_menu("context_menu") - - self.collapse_all_action = self.create_action( - OneColumnTreeActions.CollapseAllAction, - text=_("Collapse all"), - icon=ima.icon("collapse"), - triggered=self.collapseAll, - register_shortcut=False, - ) - self.expand_all_action = self.create_action( - OneColumnTreeActions.ExpandAllAction, - text=_("Expand all"), - icon=ima.icon("expand"), - triggered=self.expandAll, - register_shortcut=False, - ) - self.restore_action = self.create_action( - OneColumnTreeActions.RestoreAction, - text=_("Restore"), - tip=_("Restore original tree layout"), - icon=ima.icon("restore"), - triggered=self.restore, - register_shortcut=False, - ) - self.collapse_selection_action = self.create_action( - OneColumnTreeActions.CollapseSelectionAction, - text=_("Collapse section"), - icon=ima.icon("collapse_selection"), - triggered=self.collapse_selection, - register_shortcut=False, - ) - self.expand_selection_action = self.create_action( - OneColumnTreeActions.ExpandSelectionAction, - text=_("Expand section"), - icon=ima.icon("expand_selection"), - triggered=self.expand_selection, - register_shortcut=False, - ) - - for item in [self.collapse_all_action, self.expand_all_action]: - self.add_item_to_menu( - item, - self.menu, - section=OneColumnTreeContextMenuSections.Global, - ) - - self.add_item_to_menu( - self.restore_action, - self.menu, - section=OneColumnTreeContextMenuSections.Restore, - ) - for item in [self.collapse_selection_action, - self.expand_selection_action]: - self.add_item_to_menu( - item, - self.menu, - section=OneColumnTreeContextMenuSections.Section, - ) - - def update_actions(self): - pass - - # ---- Public API - # ------------------------------------------------------------------------- - def activated(self, item): - """Double-click event""" - raise NotImplementedError - - def clicked(self, item): - pass - - def set_title(self, title): - self.setHeaderLabels([title]) - - def setup_common_actions(self): - """Setup context menu common actions""" - return [self.collapse_all_action, self.expand_all_action, - self.collapse_selection_action, self.expand_selection_action] - - def get_menu_actions(self): - """Returns a list of menu actions""" - items = self.selectedItems() - actions = self.get_actions_from_items(items) - if actions: - actions.append(None) - - actions += self.common_actions - return actions - - def get_actions_from_items(self, items): - # Right here: add other actions if necessary - # (reimplement this method) - return [] - - @Slot() - def restore(self): - self.collapseAll() - for item in self.get_top_level_items(): - self.expandItem(item) - - def is_item_expandable(self, item): - """To be reimplemented in child class - See example in project explorer widget""" - return True - - def __expand_item(self, item): - if self.is_item_expandable(item): - self.expandItem(item) - for index in range(item.childCount()): - child = item.child(index) - self.__expand_item(child) - - @Slot() - def expand_selection(self): - items = self.selectedItems() - if not items: - items = self.get_top_level_items() - for item in items: - self.__expand_item(item) - if items: - self.scrollToItem(items[0]) - - def __collapse_item(self, item): - self.collapseItem(item) - for index in range(item.childCount()): - child = item.child(index) - self.__collapse_item(child) - - @Slot() - def collapse_selection(self): - items = self.selectedItems() - if not items: - items = self.get_top_level_items() - for item in items: - self.__collapse_item(item) - if items: - self.scrollToItem(items[0]) - - def item_selection_changed(self): - """Item selection has changed""" - is_selection = len(self.selectedItems()) > 0 - self.expand_selection_action.setEnabled(is_selection) - self.collapse_selection_action.setEnabled(is_selection) - - def get_top_level_items(self): - """Iterate over top level items""" - return [self.topLevelItem(_i) for _i in range(self.topLevelItemCount())] - - def get_items(self): - """Return items (excluding top level items)""" - itemlist = [] - def add_to_itemlist(item): - for index in range(item.childCount()): - citem = item.child(index) - itemlist.append(citem) - add_to_itemlist(citem) - for tlitem in self.get_top_level_items(): - add_to_itemlist(tlitem) - return itemlist - - def get_scrollbar_position(self): - return (self.horizontalScrollBar().value(), - self.verticalScrollBar().value()) - - def set_scrollbar_position(self, position): - hor, ver = position - self.horizontalScrollBar().setValue(hor) - self.verticalScrollBar().setValue(ver) - - def get_expanded_state(self): - self.save_expanded_state() - return self.__expanded_state - - def set_expanded_state(self, state): - self.__expanded_state = state - self.restore_expanded_state() - - def save_expanded_state(self): - """Save all items expanded state""" - self.__expanded_state = {} - def add_to_state(item): - user_text = get_item_user_text(item) - self.__expanded_state[hash(user_text)] = item.isExpanded() - def browse_children(item): - add_to_state(item) - for index in range(item.childCount()): - citem = item.child(index) - user_text = get_item_user_text(citem) - self.__expanded_state[hash(user_text)] = citem.isExpanded() - browse_children(citem) - for tlitem in self.get_top_level_items(): - browse_children(tlitem) - - def restore_expanded_state(self): - """Restore all items expanded state""" - if self.__expanded_state is None: - return - for item in self.get_items()+self.get_top_level_items(): - user_text = get_item_user_text(item) - is_expanded = self.__expanded_state.get(hash(user_text)) - if is_expanded is not None: - item.setExpanded(is_expanded) - - def sort_top_level_items(self, key): - """Sorting tree wrt top level items""" - self.save_expanded_state() - items = sorted([self.takeTopLevelItem(0) - for index in range(self.topLevelItemCount())], key=key) - for index, item in enumerate(items): - self.insertTopLevelItem(index, item) - self.restore_expanded_state() - - # ---- Qt methods - # ------------------------------------------------------------------------- - def contextMenuEvent(self, event): - """Override Qt method""" - self.menu.popup(event.globalPos()) - - def mouseMoveEvent(self, event): - """Change cursor shape.""" - index = self.indexAt(event.pos()) - if index.isValid(): - vrect = self.visualRect(index) - item_identation = vrect.x() - self.visualRect(self.rootIndex()).x() - if event.pos().x() > item_identation: - # When hovering over results - self.setCursor(Qt.PointingHandCursor) - else: - # On every other element - self.setCursor(Qt.ArrowCursor) +# -*- coding: utf-8 -*- +# +# Copyright © Spyder Project Contributors +# Licensed under the terms of the MIT License +# (see spyder/__init__.py for details) + +# Third party imports +from qtpy import PYQT5 +from qtpy.QtCore import Qt, Slot +from qtpy.QtWidgets import QAbstractItemView, QHeaderView, QTreeWidget + +# Local imports +from spyder.api.widgets.mixins import SpyderWidgetMixin +from spyder.config.base import _ +from spyder.utils.icon_manager import ima +from spyder.utils.qthelpers import get_item_user_text + + +class OneColumnTreeActions: + CollapseAllAction = "collapse_all_action" + ExpandAllAction = "expand_all_action" + RestoreAction = "restore_action" + CollapseSelectionAction = "collapse_selection_action" + ExpandSelectionAction = "expand_selection_action" + + +class OneColumnTreeContextMenuSections: + Global = "global_section" + Restore = "restore_section" + Section = "section_section" + History = "history_section" + + +class OneColumnTree(QTreeWidget, SpyderWidgetMixin): + """ + One-column tree widget with context menu. + """ + + def __init__(self, parent): + if PYQT5: + super().__init__(parent, class_parent=parent) + else: + QTreeWidget.__init__(self, parent) + SpyderWidgetMixin.__init__(self, class_parent=parent) + + self.__expanded_state = None + + # Widget setup + self.setItemsExpandable(True) + self.setColumnCount(1) + + # Setup context menu + self.collapse_all_action = None + self.collapse_selection_action = None + self.expand_all_action = None + self.expand_selection_action = None + self.setup() + self.common_actions = self.setup_common_actions() + + # Signals + self.itemActivated.connect(self.activated) + self.itemClicked.connect(self.clicked) + self.itemSelectionChanged.connect(self.item_selection_changed) + + # To use mouseMoveEvent + self.setMouseTracking(True) + + # Use horizontal scrollbar when needed + self.setHorizontalScrollMode(QAbstractItemView.ScrollPerPixel) + self.header().setSectionResizeMode(0, QHeaderView.ResizeToContents) + self.header().setStretchLastSection(False) + + self.item_selection_changed() + + # ---- SpyderWidgetMixin API + # ------------------------------------------------------------------------- + def setup(self): + self.menu = self.create_menu("context_menu") + + self.collapse_all_action = self.create_action( + OneColumnTreeActions.CollapseAllAction, + text=_("Collapse all"), + icon=ima.icon("collapse"), + triggered=self.collapseAll, + register_shortcut=False, + ) + self.expand_all_action = self.create_action( + OneColumnTreeActions.ExpandAllAction, + text=_("Expand all"), + icon=ima.icon("expand"), + triggered=self.expandAll, + register_shortcut=False, + ) + self.restore_action = self.create_action( + OneColumnTreeActions.RestoreAction, + text=_("Restore"), + tip=_("Restore original tree layout"), + icon=ima.icon("restore"), + triggered=self.restore, + register_shortcut=False, + ) + self.collapse_selection_action = self.create_action( + OneColumnTreeActions.CollapseSelectionAction, + text=_("Collapse section"), + icon=ima.icon("collapse_selection"), + triggered=self.collapse_selection, + register_shortcut=False, + ) + self.expand_selection_action = self.create_action( + OneColumnTreeActions.ExpandSelectionAction, + text=_("Expand section"), + icon=ima.icon("expand_selection"), + triggered=self.expand_selection, + register_shortcut=False, + ) + + for item in [self.collapse_all_action, self.expand_all_action]: + self.add_item_to_menu( + item, + self.menu, + section=OneColumnTreeContextMenuSections.Global, + ) + + self.add_item_to_menu( + self.restore_action, + self.menu, + section=OneColumnTreeContextMenuSections.Restore, + ) + for item in [self.collapse_selection_action, + self.expand_selection_action]: + self.add_item_to_menu( + item, + self.menu, + section=OneColumnTreeContextMenuSections.Section, + ) + + def update_actions(self): + pass + + # ---- Public API + # ------------------------------------------------------------------------- + def activated(self, item): + """Double-click event""" + raise NotImplementedError + + def clicked(self, item): + pass + + def set_title(self, title): + self.setHeaderLabels([title]) + + def setup_common_actions(self): + """Setup context menu common actions""" + return [self.collapse_all_action, self.expand_all_action, + self.collapse_selection_action, self.expand_selection_action] + + def get_menu_actions(self): + """Returns a list of menu actions""" + items = self.selectedItems() + actions = self.get_actions_from_items(items) + if actions: + actions.append(None) + + actions += self.common_actions + return actions + + def get_actions_from_items(self, items): + # Right here: add other actions if necessary + # (reimplement this method) + return [] + + @Slot() + def restore(self): + self.collapseAll() + for item in self.get_top_level_items(): + self.expandItem(item) + + def is_item_expandable(self, item): + """To be reimplemented in child class + See example in project explorer widget""" + return True + + def __expand_item(self, item): + if self.is_item_expandable(item): + self.expandItem(item) + for index in range(item.childCount()): + child = item.child(index) + self.__expand_item(child) + + @Slot() + def expand_selection(self): + items = self.selectedItems() + if not items: + items = self.get_top_level_items() + for item in items: + self.__expand_item(item) + if items: + self.scrollToItem(items[0]) + + def __collapse_item(self, item): + self.collapseItem(item) + for index in range(item.childCount()): + child = item.child(index) + self.__collapse_item(child) + + @Slot() + def collapse_selection(self): + items = self.selectedItems() + if not items: + items = self.get_top_level_items() + for item in items: + self.__collapse_item(item) + if items: + self.scrollToItem(items[0]) + + def item_selection_changed(self): + """Item selection has changed""" + is_selection = len(self.selectedItems()) > 0 + self.expand_selection_action.setEnabled(is_selection) + self.collapse_selection_action.setEnabled(is_selection) + + def get_top_level_items(self): + """Iterate over top level items""" + return [self.topLevelItem(_i) for _i in range(self.topLevelItemCount())] + + def get_items(self): + """Return items (excluding top level items)""" + itemlist = [] + def add_to_itemlist(item): + for index in range(item.childCount()): + citem = item.child(index) + itemlist.append(citem) + add_to_itemlist(citem) + for tlitem in self.get_top_level_items(): + add_to_itemlist(tlitem) + return itemlist + + def get_scrollbar_position(self): + return (self.horizontalScrollBar().value(), + self.verticalScrollBar().value()) + + def set_scrollbar_position(self, position): + hor, ver = position + self.horizontalScrollBar().setValue(hor) + self.verticalScrollBar().setValue(ver) + + def get_expanded_state(self): + self.save_expanded_state() + return self.__expanded_state + + def set_expanded_state(self, state): + self.__expanded_state = state + self.restore_expanded_state() + + def save_expanded_state(self): + """Save all items expanded state""" + self.__expanded_state = {} + def add_to_state(item): + user_text = get_item_user_text(item) + self.__expanded_state[hash(user_text)] = item.isExpanded() + def browse_children(item): + add_to_state(item) + for index in range(item.childCount()): + citem = item.child(index) + user_text = get_item_user_text(citem) + self.__expanded_state[hash(user_text)] = citem.isExpanded() + browse_children(citem) + for tlitem in self.get_top_level_items(): + browse_children(tlitem) + + def restore_expanded_state(self): + """Restore all items expanded state""" + if self.__expanded_state is None: + return + for item in self.get_items()+self.get_top_level_items(): + user_text = get_item_user_text(item) + is_expanded = self.__expanded_state.get(hash(user_text)) + if is_expanded is not None: + item.setExpanded(is_expanded) + + def sort_top_level_items(self, key): + """Sorting tree wrt top level items""" + self.save_expanded_state() + items = sorted([self.takeTopLevelItem(0) + for index in range(self.topLevelItemCount())], key=key) + for index, item in enumerate(items): + self.insertTopLevelItem(index, item) + self.restore_expanded_state() + + # ---- Qt methods + # ------------------------------------------------------------------------- + def contextMenuEvent(self, event): + """Override Qt method""" + self.menu.popup(event.globalPos()) + + def mouseMoveEvent(self, event): + """Change cursor shape.""" + index = self.indexAt(event.pos()) + if index.isValid(): + vrect = self.visualRect(index) + item_identation = vrect.x() - self.visualRect(self.rootIndex()).x() + if event.pos().x() > item_identation: + # When hovering over results + self.setCursor(Qt.PointingHandCursor) + else: + # On every other element + self.setCursor(Qt.ArrowCursor) diff --git a/spyder/widgets/pathmanager.py b/spyder/widgets/pathmanager.py index 4c1c1a2f508..f562282f772 100644 --- a/spyder/widgets/pathmanager.py +++ b/spyder/widgets/pathmanager.py @@ -1,506 +1,506 @@ -# -*- coding: utf-8 -*- -# -# Copyright © Spyder Project Contributors -# Licensed under the terms of the MIT License -# (see spyder/__init__.py for details) - -"""Spyder path manager.""" - -# Standard library imports -from __future__ import print_function -from collections import OrderedDict -import os -import os.path as osp -import re -import sys - -# Third party imports -from qtpy.compat import getexistingdirectory -from qtpy.QtCore import Qt, Signal, Slot -from qtpy.QtWidgets import (QDialog, QDialogButtonBox, QHBoxLayout, - QListWidget, QListWidgetItem, QMessageBox, - QVBoxLayout, QLabel) - -# Local imports -from spyder.config.base import _ -from spyder.utils.icon_manager import ima -from spyder.utils.misc import getcwd_or_home -from spyder.utils.qthelpers import create_toolbutton - - -class PathManager(QDialog): - """Path manager dialog.""" - redirect_stdio = Signal(bool) - sig_path_changed = Signal(object) - - def __init__(self, parent, path=None, read_only_path=None, - not_active_path=None, sync=True): - """Path manager dialog.""" - super(PathManager, self).__init__(parent) - assert isinstance(path, (tuple, type(None))) - - self.path = path or () - self.read_only_path = read_only_path or () - self.not_active_path = not_active_path or () - self.last_path = getcwd_or_home() - self.original_path_dict = None - - # Widgets - self.add_button = None - self.remove_button = None - self.movetop_button = None - self.moveup_button = None - self.movedown_button = None - self.movebottom_button = None - self.import_button = None - self.export_button = None - self.selection_widgets = [] - self.top_toolbar_widgets = self._setup_top_toolbar() - self.bottom_toolbar_widgets = self._setup_bottom_toolbar() - self.listwidget = QListWidget(self) - self.bbox = QDialogButtonBox(QDialogButtonBox.Ok - | QDialogButtonBox.Cancel) - self.button_ok = self.bbox.button(QDialogButtonBox.Ok) - - # Widget 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 - # a segmentation fault on UNIX or an application crash on Windows - self.setAttribute(Qt.WA_DeleteOnClose) - self.setWindowTitle(_("PYTHONPATH manager")) - self.setWindowIcon(ima.icon('pythonpath')) - self.resize(500, 400) - self.import_button.setVisible(sync) - self.export_button.setVisible(os.name == 'nt' and sync) - - # Layouts - description = QLabel( - _("The paths listed below will be passed to IPython consoles and " - "the language server as additional locations to search for " - "Python modules.

    " - "Any paths in your system PYTHONPATH environment " - "variable can be imported here if you'd like to use them.") - ) - description.setWordWrap(True) - top_layout = QHBoxLayout() - self._add_widgets_to_layout(self.top_toolbar_widgets, top_layout) - - bottom_layout = QHBoxLayout() - self._add_widgets_to_layout(self.bottom_toolbar_widgets, - bottom_layout) - bottom_layout.addWidget(self.bbox) - - layout = QVBoxLayout() - layout.addWidget(description) - layout.addLayout(top_layout) - layout.addWidget(self.listwidget) - layout.addLayout(bottom_layout) - self.setLayout(layout) - - # Signals - self.listwidget.currentRowChanged.connect(lambda x: self.refresh()) - self.listwidget.itemChanged.connect(lambda x: self.refresh()) - self.bbox.accepted.connect(self.accept) - self.bbox.rejected.connect(self.reject) - - # Setup - self.setup() - - def _add_widgets_to_layout(self, widgets, layout): - """Helper to add toolbar widgets to top and bottom layout.""" - layout.setAlignment(Qt.AlignLeft) - for widget in widgets: - if widget is None: - layout.addStretch(1) - else: - layout.addWidget(widget) - - def _setup_top_toolbar(self): - """Create top toolbar and actions.""" - self.movetop_button = create_toolbutton( - self, - text=_("Move to top"), - icon=ima.icon('2uparrow'), - triggered=lambda: self.move_to(absolute=0), - text_beside_icon=True) - self.moveup_button = create_toolbutton( - self, - text=_("Move up"), - icon=ima.icon('1uparrow'), - triggered=lambda: self.move_to(relative=-1), - text_beside_icon=True) - self.movedown_button = create_toolbutton( - self, - text=_("Move down"), - icon=ima.icon('1downarrow'), - triggered=lambda: self.move_to(relative=1), - text_beside_icon=True) - self.movebottom_button = create_toolbutton( - self, - text=_("Move to bottom"), - icon=ima.icon('2downarrow'), - triggered=lambda: self.move_to(absolute=1), - text_beside_icon=True) - - toolbar = [self.movetop_button, self.moveup_button, - self.movedown_button, self.movebottom_button] - self.selection_widgets.extend(toolbar) - return toolbar - - def _setup_bottom_toolbar(self): - """Create bottom toolbar and actions.""" - self.add_button = create_toolbutton( - self, - text=_('Add path'), - icon=ima.icon('edit_add'), - triggered=lambda x: self.add_path(), - text_beside_icon=True) - self.remove_button = create_toolbutton( - self, - text=_('Remove path'), - icon=ima.icon('edit_remove'), - triggered=lambda x: self.remove_path(), - text_beside_icon=True) - self.import_button = create_toolbutton( - self, - text=_("Import"), - icon=ima.icon('fileimport'), - triggered=self.import_pythonpath, - tip=_("Import from PYTHONPATH environment variable"), - text_beside_icon=True) - self.export_button = create_toolbutton( - self, - text=_("Export"), - icon=ima.icon('fileexport'), - triggered=self.export_pythonpath, - tip=_("Export to PYTHONPATH environment variable"), - text_beside_icon=True) - - return [self.add_button, self.remove_button, self.import_button, - self.export_button] - - def _create_item(self, path): - """Helper to create a new list item.""" - item = QListWidgetItem(path) - item.setIcon(ima.icon('DirClosedIcon')) - - if path in self.read_only_path: - item.setFlags(Qt.NoItemFlags | Qt.ItemIsUserCheckable) - item.setCheckState(Qt.Checked) - elif path in self.not_active_path: - item.setFlags(item.flags() | Qt.ItemIsUserCheckable) - item.setCheckState(Qt.Unchecked) - else: - item.setFlags(item.flags() | Qt.ItemIsUserCheckable) - item.setCheckState(Qt.Checked) - - return item - - @property - def editable_bottom_row(self): - """Maximum bottom row count that is editable.""" - read_only_count = len(self.read_only_path) - max_row = self.listwidget.count() - read_only_count - 1 - return max_row - - def setup(self): - """Populate list widget.""" - self.listwidget.clear() - for path in self.path + self.read_only_path: - item = self._create_item(path) - self.listwidget.addItem(item) - self.listwidget.setCurrentRow(0) - self.original_path_dict = self.get_path_dict() - self.refresh() - - @Slot() - def import_pythonpath(self): - """Import from PYTHONPATH environment variable""" - env_pypath = os.environ.get('PYTHONPATH', '') - - if env_pypath: - env_pypath = env_pypath.split(os.pathsep) - - dlg = QDialog(self) - dlg.setWindowTitle(_("PYTHONPATH")) - dlg.setWindowIcon(ima.icon('pythonpath')) - dlg.setAttribute(Qt.WA_DeleteOnClose) - dlg.setMinimumWidth(400) - - label = QLabel("The following paths from your PYTHONPATH " - "environment variable will be imported.") - listw = QListWidget(dlg) - listw.addItems(env_pypath) - - bbox = QDialogButtonBox( - QDialogButtonBox.Ok | QDialogButtonBox.Cancel) - bbox.accepted.connect(dlg.accept) - bbox.rejected.connect(dlg.reject) - - layout = QVBoxLayout() - layout.addWidget(label) - layout.addWidget(listw) - layout.addWidget(bbox) - dlg.setLayout(layout) - - if dlg.exec(): - spy_pypath = self.get_path_dict() - n = len(spy_pypath) - - for path in reversed(env_pypath): - if (path in spy_pypath) or not self.check_path(path): - continue - item = self._create_item(path) - self.listwidget.insertItem(n, item) - - self.refresh() - else: - QMessageBox.information( - self, - _("PYTHONPATH"), - _("Your PYTHONPATH environment variable is empty, so " - "there is nothing to import."), - QMessageBox.Ok - ) - - @Slot() - def export_pythonpath(self): - """ - Export to PYTHONPATH environment variable - Only apply to: current user. - """ - answer = QMessageBox.question( - self, - _("Export"), - _("This will export Spyder's path list to the " - "PYTHONPATH environment variable for the current user, " - "allowing you to run your Python modules outside Spyder " - "without having to configure sys.path. " - "

    " - "Do you want to clear the contents of PYTHONPATH before " - "adding Spyder's path list?"), - QMessageBox.Yes | QMessageBox.No | QMessageBox.Cancel - ) - - if answer == QMessageBox.Cancel: - return - elif answer == QMessageBox.Yes: - remove = True - else: - remove = False - - from spyder.utils.environ import (get_user_env, listdict2envdict, - set_user_env) - env = get_user_env() - - # Includes read only paths - active_path = tuple(k for k, v in self.get_path_dict(True).items() - if v) - - if remove: - ppath = active_path - else: - ppath = env.get('PYTHONPATH', []) - if not isinstance(ppath, list): - ppath = [ppath] - - ppath = tuple(p for p in ppath if p not in active_path) - ppath = ppath + active_path - - env['PYTHONPATH'] = list(ppath) - set_user_env(listdict2envdict(env), parent=self) - - def get_path_dict(self, read_only=False): - """ - Return an ordered dict with the path entries as keys and the active - state as the value. - - If `read_only` is True, the read_only entries are also included. - `read_only` entry refers to the project path entry. - """ - odict = OrderedDict() - for row in range(self.listwidget.count()): - item = self.listwidget.item(row) - path = item.text() - if path in self.read_only_path and not read_only: - continue - odict[path] = item.checkState() == Qt.Checked - return odict - - def refresh(self): - """Refresh toolbar widgets.""" - enabled = self.listwidget.currentItem() is not None - for widget in self.selection_widgets: - widget.setEnabled(enabled) - - # Disable buttons based on row - row = self.listwidget.currentRow() - disable_widgets = [] - - # Move up/top disabled for top item - if row == 0: - disable_widgets.extend([self.movetop_button, self.moveup_button]) - - # Move down/bottom disabled for bottom item - if row == self.editable_bottom_row: - disable_widgets.extend([self.movebottom_button, - self.movedown_button]) - for widget in disable_widgets: - widget.setEnabled(False) - - self.remove_button.setEnabled(self.listwidget.count() - - len(self.read_only_path)) - self.export_button.setEnabled(self.listwidget.count() > 0) - - # Ok button only enabled if actual changes occur - self.button_ok.setEnabled( - self.original_path_dict != self.get_path_dict()) - - def check_path(self, path): - """Check that the path is not a [site|dist]-packages folder.""" - if os.name == 'nt': - pat = re.compile(r'.*lib/(?:site|dist)-packages.*') - else: - pat = re.compile(r'.*lib/python.../(?:site|dist)-packages.*') - - path_norm = path.replace('\\', '/') - return pat.match(path_norm) is None - - @Slot() - def add_path(self, directory=None): - """ - Add path to list widget. - - If `directory` is provided, the folder dialog is overridden. - """ - if directory is None: - self.redirect_stdio.emit(False) - directory = getexistingdirectory(self, _("Select directory"), - self.last_path) - self.redirect_stdio.emit(True) - if not directory: - return - - directory = osp.abspath(directory) - self.last_path = directory - - if directory in self.get_path_dict(): - item = self.listwidget.findItems(directory, Qt.MatchExactly)[0] - item.setCheckState(Qt.Checked) - answer = QMessageBox.question( - self, - _("Add path"), - _("This directory is already included in the list." - "
    " - "Do you want to move it to the top of it?"), - QMessageBox.Yes | QMessageBox.No) - - if answer == QMessageBox.Yes: - item = self.listwidget.takeItem(self.listwidget.row(item)) - self.listwidget.insertItem(0, item) - self.listwidget.setCurrentRow(0) - else: - if self.check_path(directory): - item = self._create_item(directory) - self.listwidget.insertItem(0, item) - self.listwidget.setCurrentRow(0) - else: - answer = QMessageBox.warning( - self, - _("Add path"), - _("This directory cannot be added to the path!" - "

    " - "If you want to set a different Python interpreter, " - "please go to Preferences > Main interpreter" - "."), - QMessageBox.Ok) - - self.refresh() - - @Slot() - def remove_path(self, force=False): - """ - Remove path from list widget. - - If `force` is True, the message box is overridden. - """ - if self.listwidget.currentItem(): - if not force: - answer = QMessageBox.warning( - self, - _("Remove path"), - _("Do you really want to remove the selected path?"), - QMessageBox.Yes | QMessageBox.No) - - if force or answer == QMessageBox.Yes: - self.listwidget.takeItem(self.listwidget.currentRow()) - self.refresh() - - def move_to(self, absolute=None, relative=None): - """Move items of list widget.""" - index = self.listwidget.currentRow() - if absolute is not None: - if absolute: - new_index = self.listwidget.count() - 1 - else: - new_index = 0 - else: - new_index = index + relative - - new_index = max(0, min(self.editable_bottom_row, new_index)) - item = self.listwidget.takeItem(index) - self.listwidget.insertItem(new_index, item) - self.listwidget.setCurrentRow(new_index) - self.refresh() - - def current_row(self): - """Returns the current row of the list.""" - return self.listwidget.currentRow() - - def set_current_row(self, row): - """Set the current row of the list.""" - self.listwidget.setCurrentRow(row) - - def row_check_state(self, row): - """Return the checked state for item in row.""" - item = self.listwidget.item(row) - return item.checkState() - - def set_row_check_state(self, row, value): - """Set the current checked state for item in row.""" - item = self.listwidget.item(row) - item.setCheckState(value) - - def count(self): - """Return the number of items.""" - return self.listwidget.count() - - def accept(self): - """Override Qt method.""" - path_dict = self.get_path_dict() - if self.original_path_dict != path_dict: - self.sig_path_changed.emit(path_dict) - super(PathManager, self).accept() - - -def test(): - """Run path manager test.""" - from spyder.utils.qthelpers import qapplication - - _ = qapplication() - dlg = PathManager( - None, - path=tuple(sys.path[4:-2]), - read_only_path=tuple(sys.path[-2:]), - ) - - def callback(path_dict): - sys.stdout.write(str(path_dict)) - - dlg.sig_path_changed.connect(callback) - sys.exit(dlg.exec_()) - - -if __name__ == "__main__": - test() +# -*- coding: utf-8 -*- +# +# Copyright © Spyder Project Contributors +# Licensed under the terms of the MIT License +# (see spyder/__init__.py for details) + +"""Spyder path manager.""" + +# Standard library imports +from __future__ import print_function +from collections import OrderedDict +import os +import os.path as osp +import re +import sys + +# Third party imports +from qtpy.compat import getexistingdirectory +from qtpy.QtCore import Qt, Signal, Slot +from qtpy.QtWidgets import (QDialog, QDialogButtonBox, QHBoxLayout, + QListWidget, QListWidgetItem, QMessageBox, + QVBoxLayout, QLabel) + +# Local imports +from spyder.config.base import _ +from spyder.utils.icon_manager import ima +from spyder.utils.misc import getcwd_or_home +from spyder.utils.qthelpers import create_toolbutton + + +class PathManager(QDialog): + """Path manager dialog.""" + redirect_stdio = Signal(bool) + sig_path_changed = Signal(object) + + def __init__(self, parent, path=None, read_only_path=None, + not_active_path=None, sync=True): + """Path manager dialog.""" + super(PathManager, self).__init__(parent) + assert isinstance(path, (tuple, type(None))) + + self.path = path or () + self.read_only_path = read_only_path or () + self.not_active_path = not_active_path or () + self.last_path = getcwd_or_home() + self.original_path_dict = None + + # Widgets + self.add_button = None + self.remove_button = None + self.movetop_button = None + self.moveup_button = None + self.movedown_button = None + self.movebottom_button = None + self.import_button = None + self.export_button = None + self.selection_widgets = [] + self.top_toolbar_widgets = self._setup_top_toolbar() + self.bottom_toolbar_widgets = self._setup_bottom_toolbar() + self.listwidget = QListWidget(self) + self.bbox = QDialogButtonBox(QDialogButtonBox.Ok + | QDialogButtonBox.Cancel) + self.button_ok = self.bbox.button(QDialogButtonBox.Ok) + + # Widget 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 + # a segmentation fault on UNIX or an application crash on Windows + self.setAttribute(Qt.WA_DeleteOnClose) + self.setWindowTitle(_("PYTHONPATH manager")) + self.setWindowIcon(ima.icon('pythonpath')) + self.resize(500, 400) + self.import_button.setVisible(sync) + self.export_button.setVisible(os.name == 'nt' and sync) + + # Layouts + description = QLabel( + _("The paths listed below will be passed to IPython consoles and " + "the language server as additional locations to search for " + "Python modules.

    " + "Any paths in your system PYTHONPATH environment " + "variable can be imported here if you'd like to use them.") + ) + description.setWordWrap(True) + top_layout = QHBoxLayout() + self._add_widgets_to_layout(self.top_toolbar_widgets, top_layout) + + bottom_layout = QHBoxLayout() + self._add_widgets_to_layout(self.bottom_toolbar_widgets, + bottom_layout) + bottom_layout.addWidget(self.bbox) + + layout = QVBoxLayout() + layout.addWidget(description) + layout.addLayout(top_layout) + layout.addWidget(self.listwidget) + layout.addLayout(bottom_layout) + self.setLayout(layout) + + # Signals + self.listwidget.currentRowChanged.connect(lambda x: self.refresh()) + self.listwidget.itemChanged.connect(lambda x: self.refresh()) + self.bbox.accepted.connect(self.accept) + self.bbox.rejected.connect(self.reject) + + # Setup + self.setup() + + def _add_widgets_to_layout(self, widgets, layout): + """Helper to add toolbar widgets to top and bottom layout.""" + layout.setAlignment(Qt.AlignLeft) + for widget in widgets: + if widget is None: + layout.addStretch(1) + else: + layout.addWidget(widget) + + def _setup_top_toolbar(self): + """Create top toolbar and actions.""" + self.movetop_button = create_toolbutton( + self, + text=_("Move to top"), + icon=ima.icon('2uparrow'), + triggered=lambda: self.move_to(absolute=0), + text_beside_icon=True) + self.moveup_button = create_toolbutton( + self, + text=_("Move up"), + icon=ima.icon('1uparrow'), + triggered=lambda: self.move_to(relative=-1), + text_beside_icon=True) + self.movedown_button = create_toolbutton( + self, + text=_("Move down"), + icon=ima.icon('1downarrow'), + triggered=lambda: self.move_to(relative=1), + text_beside_icon=True) + self.movebottom_button = create_toolbutton( + self, + text=_("Move to bottom"), + icon=ima.icon('2downarrow'), + triggered=lambda: self.move_to(absolute=1), + text_beside_icon=True) + + toolbar = [self.movetop_button, self.moveup_button, + self.movedown_button, self.movebottom_button] + self.selection_widgets.extend(toolbar) + return toolbar + + def _setup_bottom_toolbar(self): + """Create bottom toolbar and actions.""" + self.add_button = create_toolbutton( + self, + text=_('Add path'), + icon=ima.icon('edit_add'), + triggered=lambda x: self.add_path(), + text_beside_icon=True) + self.remove_button = create_toolbutton( + self, + text=_('Remove path'), + icon=ima.icon('edit_remove'), + triggered=lambda x: self.remove_path(), + text_beside_icon=True) + self.import_button = create_toolbutton( + self, + text=_("Import"), + icon=ima.icon('fileimport'), + triggered=self.import_pythonpath, + tip=_("Import from PYTHONPATH environment variable"), + text_beside_icon=True) + self.export_button = create_toolbutton( + self, + text=_("Export"), + icon=ima.icon('fileexport'), + triggered=self.export_pythonpath, + tip=_("Export to PYTHONPATH environment variable"), + text_beside_icon=True) + + return [self.add_button, self.remove_button, self.import_button, + self.export_button] + + def _create_item(self, path): + """Helper to create a new list item.""" + item = QListWidgetItem(path) + item.setIcon(ima.icon('DirClosedIcon')) + + if path in self.read_only_path: + item.setFlags(Qt.NoItemFlags | Qt.ItemIsUserCheckable) + item.setCheckState(Qt.Checked) + elif path in self.not_active_path: + item.setFlags(item.flags() | Qt.ItemIsUserCheckable) + item.setCheckState(Qt.Unchecked) + else: + item.setFlags(item.flags() | Qt.ItemIsUserCheckable) + item.setCheckState(Qt.Checked) + + return item + + @property + def editable_bottom_row(self): + """Maximum bottom row count that is editable.""" + read_only_count = len(self.read_only_path) + max_row = self.listwidget.count() - read_only_count - 1 + return max_row + + def setup(self): + """Populate list widget.""" + self.listwidget.clear() + for path in self.path + self.read_only_path: + item = self._create_item(path) + self.listwidget.addItem(item) + self.listwidget.setCurrentRow(0) + self.original_path_dict = self.get_path_dict() + self.refresh() + + @Slot() + def import_pythonpath(self): + """Import from PYTHONPATH environment variable""" + env_pypath = os.environ.get('PYTHONPATH', '') + + if env_pypath: + env_pypath = env_pypath.split(os.pathsep) + + dlg = QDialog(self) + dlg.setWindowTitle(_("PYTHONPATH")) + dlg.setWindowIcon(ima.icon('pythonpath')) + dlg.setAttribute(Qt.WA_DeleteOnClose) + dlg.setMinimumWidth(400) + + label = QLabel("The following paths from your PYTHONPATH " + "environment variable will be imported.") + listw = QListWidget(dlg) + listw.addItems(env_pypath) + + bbox = QDialogButtonBox( + QDialogButtonBox.Ok | QDialogButtonBox.Cancel) + bbox.accepted.connect(dlg.accept) + bbox.rejected.connect(dlg.reject) + + layout = QVBoxLayout() + layout.addWidget(label) + layout.addWidget(listw) + layout.addWidget(bbox) + dlg.setLayout(layout) + + if dlg.exec(): + spy_pypath = self.get_path_dict() + n = len(spy_pypath) + + for path in reversed(env_pypath): + if (path in spy_pypath) or not self.check_path(path): + continue + item = self._create_item(path) + self.listwidget.insertItem(n, item) + + self.refresh() + else: + QMessageBox.information( + self, + _("PYTHONPATH"), + _("Your PYTHONPATH environment variable is empty, so " + "there is nothing to import."), + QMessageBox.Ok + ) + + @Slot() + def export_pythonpath(self): + """ + Export to PYTHONPATH environment variable + Only apply to: current user. + """ + answer = QMessageBox.question( + self, + _("Export"), + _("This will export Spyder's path list to the " + "PYTHONPATH environment variable for the current user, " + "allowing you to run your Python modules outside Spyder " + "without having to configure sys.path. " + "

    " + "Do you want to clear the contents of PYTHONPATH before " + "adding Spyder's path list?"), + QMessageBox.Yes | QMessageBox.No | QMessageBox.Cancel + ) + + if answer == QMessageBox.Cancel: + return + elif answer == QMessageBox.Yes: + remove = True + else: + remove = False + + from spyder.utils.environ import (get_user_env, listdict2envdict, + set_user_env) + env = get_user_env() + + # Includes read only paths + active_path = tuple(k for k, v in self.get_path_dict(True).items() + if v) + + if remove: + ppath = active_path + else: + ppath = env.get('PYTHONPATH', []) + if not isinstance(ppath, list): + ppath = [ppath] + + ppath = tuple(p for p in ppath if p not in active_path) + ppath = ppath + active_path + + env['PYTHONPATH'] = list(ppath) + set_user_env(listdict2envdict(env), parent=self) + + def get_path_dict(self, read_only=False): + """ + Return an ordered dict with the path entries as keys and the active + state as the value. + + If `read_only` is True, the read_only entries are also included. + `read_only` entry refers to the project path entry. + """ + odict = OrderedDict() + for row in range(self.listwidget.count()): + item = self.listwidget.item(row) + path = item.text() + if path in self.read_only_path and not read_only: + continue + odict[path] = item.checkState() == Qt.Checked + return odict + + def refresh(self): + """Refresh toolbar widgets.""" + enabled = self.listwidget.currentItem() is not None + for widget in self.selection_widgets: + widget.setEnabled(enabled) + + # Disable buttons based on row + row = self.listwidget.currentRow() + disable_widgets = [] + + # Move up/top disabled for top item + if row == 0: + disable_widgets.extend([self.movetop_button, self.moveup_button]) + + # Move down/bottom disabled for bottom item + if row == self.editable_bottom_row: + disable_widgets.extend([self.movebottom_button, + self.movedown_button]) + for widget in disable_widgets: + widget.setEnabled(False) + + self.remove_button.setEnabled(self.listwidget.count() + - len(self.read_only_path)) + self.export_button.setEnabled(self.listwidget.count() > 0) + + # Ok button only enabled if actual changes occur + self.button_ok.setEnabled( + self.original_path_dict != self.get_path_dict()) + + def check_path(self, path): + """Check that the path is not a [site|dist]-packages folder.""" + if os.name == 'nt': + pat = re.compile(r'.*lib/(?:site|dist)-packages.*') + else: + pat = re.compile(r'.*lib/python.../(?:site|dist)-packages.*') + + path_norm = path.replace('\\', '/') + return pat.match(path_norm) is None + + @Slot() + def add_path(self, directory=None): + """ + Add path to list widget. + + If `directory` is provided, the folder dialog is overridden. + """ + if directory is None: + self.redirect_stdio.emit(False) + directory = getexistingdirectory(self, _("Select directory"), + self.last_path) + self.redirect_stdio.emit(True) + if not directory: + return + + directory = osp.abspath(directory) + self.last_path = directory + + if directory in self.get_path_dict(): + item = self.listwidget.findItems(directory, Qt.MatchExactly)[0] + item.setCheckState(Qt.Checked) + answer = QMessageBox.question( + self, + _("Add path"), + _("This directory is already included in the list." + "
    " + "Do you want to move it to the top of it?"), + QMessageBox.Yes | QMessageBox.No) + + if answer == QMessageBox.Yes: + item = self.listwidget.takeItem(self.listwidget.row(item)) + self.listwidget.insertItem(0, item) + self.listwidget.setCurrentRow(0) + else: + if self.check_path(directory): + item = self._create_item(directory) + self.listwidget.insertItem(0, item) + self.listwidget.setCurrentRow(0) + else: + answer = QMessageBox.warning( + self, + _("Add path"), + _("This directory cannot be added to the path!" + "

    " + "If you want to set a different Python interpreter, " + "please go to Preferences > Main interpreter" + "."), + QMessageBox.Ok) + + self.refresh() + + @Slot() + def remove_path(self, force=False): + """ + Remove path from list widget. + + If `force` is True, the message box is overridden. + """ + if self.listwidget.currentItem(): + if not force: + answer = QMessageBox.warning( + self, + _("Remove path"), + _("Do you really want to remove the selected path?"), + QMessageBox.Yes | QMessageBox.No) + + if force or answer == QMessageBox.Yes: + self.listwidget.takeItem(self.listwidget.currentRow()) + self.refresh() + + def move_to(self, absolute=None, relative=None): + """Move items of list widget.""" + index = self.listwidget.currentRow() + if absolute is not None: + if absolute: + new_index = self.listwidget.count() - 1 + else: + new_index = 0 + else: + new_index = index + relative + + new_index = max(0, min(self.editable_bottom_row, new_index)) + item = self.listwidget.takeItem(index) + self.listwidget.insertItem(new_index, item) + self.listwidget.setCurrentRow(new_index) + self.refresh() + + def current_row(self): + """Returns the current row of the list.""" + return self.listwidget.currentRow() + + def set_current_row(self, row): + """Set the current row of the list.""" + self.listwidget.setCurrentRow(row) + + def row_check_state(self, row): + """Return the checked state for item in row.""" + item = self.listwidget.item(row) + return item.checkState() + + def set_row_check_state(self, row, value): + """Set the current checked state for item in row.""" + item = self.listwidget.item(row) + item.setCheckState(value) + + def count(self): + """Return the number of items.""" + return self.listwidget.count() + + def accept(self): + """Override Qt method.""" + path_dict = self.get_path_dict() + if self.original_path_dict != path_dict: + self.sig_path_changed.emit(path_dict) + super(PathManager, self).accept() + + +def test(): + """Run path manager test.""" + from spyder.utils.qthelpers import qapplication + + _ = qapplication() + dlg = PathManager( + None, + path=tuple(sys.path[4:-2]), + read_only_path=tuple(sys.path[-2:]), + ) + + def callback(path_dict): + sys.stdout.write(str(path_dict)) + + dlg.sig_path_changed.connect(callback) + sys.exit(dlg.exec_()) + + +if __name__ == "__main__": + test() diff --git a/spyder/widgets/simplecodeeditor.py b/spyder/widgets/simplecodeeditor.py index 67979b0380e..986b9c8cae7 100644 --- a/spyder/widgets/simplecodeeditor.py +++ b/spyder/widgets/simplecodeeditor.py @@ -1,578 +1,578 @@ -# -*- coding: utf-8 -*- -# ----------------------------------------------------------------------------- -# Copyright © Spyder Project Contributors -# -# Licensed under the terms of the MIT License -# (see spyder/__init__.py for details) -# ---------------------------------------------------------------------------- - -""" -Simple code editor with syntax highlighting and line number area. - -Adapted from: -https://doc.qt.io/qt-5/qtwidgets-widgets-codeeditor-example.html -""" - -# Third party imports -from qtpy.QtCore import QPoint, QRect, QSize, Qt, Signal -from qtpy.QtGui import QColor, QPainter, QTextCursor, QTextFormat, QTextOption -from qtpy.QtWidgets import QPlainTextEdit, QTextEdit, QWidget - -# Local imports -import spyder.utils.syntaxhighlighters as sh -from spyder.widgets.mixins import BaseEditMixin - - -# Constants -LANGUAGE_EXTENSIONS = { - 'Python': ('py', 'pyw', 'python', 'ipy'), - 'Cython': ('pyx', 'pxi', 'pxd'), - 'Enaml': ('enaml',), - 'Fortran77': ('f', 'for', 'f77'), - 'Fortran': ('f90', 'f95', 'f2k', 'f03', 'f08'), - 'Idl': ('pro',), - 'Diff': ('diff', 'patch', 'rej'), - 'GetText': ('po', 'pot'), - 'Nsis': ('nsi', 'nsh'), - 'Html': ('htm', 'html'), - 'Cpp': ('c', 'cc', 'cpp', 'cxx', 'h', 'hh', 'hpp', 'hxx'), - 'OpenCL': ('cl',), - 'Yaml': ('yaml', 'yml'), - 'Markdown': ('md', 'mdw'), - # Every other language - 'None': ('', ), -} - - -class LineNumberArea(QWidget): - """ - Adapted from: - https://doc.qt.io/qt-5/qtwidgets-widgets-codeeditor-example.html - """ - - def __init__(self, code_editor=None): - super().__init__(code_editor) - - self._editor = code_editor - self._left_padding = 6 # Pixels - self._right_padding = 3 # Pixels - - # --- Qt overrides - # ------------------------------------------------------------------------ - def sizeHint(self): - return QSize(self._editor.linenumberarea_width(), 0) - - def paintEvent(self, event): - self._editor.linenumberarea_paint_event(event) - - -class SimpleCodeEditor(QPlainTextEdit, BaseEditMixin): - """Simple editor with highlight features.""" - - LANGUAGE_HIGHLIGHTERS = { - 'Python': (sh.PythonSH, '#'), - 'Cython': (sh.CythonSH, '#'), - 'Fortran77': (sh.Fortran77SH, 'c'), - 'Fortran': (sh.FortranSH, '!'), - 'Idl': (sh.IdlSH, ';'), - 'Diff': (sh.DiffSH, ''), - 'GetText': (sh.GetTextSH, '#'), - 'Nsis': (sh.NsisSH, '#'), - 'Html': (sh.HtmlSH, ''), - 'Yaml': (sh.YamlSH, '#'), - 'Cpp': (sh.CppSH, '//'), - 'OpenCL': (sh.OpenCLSH, '//'), - 'Enaml': (sh.EnamlSH, '#'), - 'Markdown': (sh.MarkdownSH, '#'), - # Every other language - 'None': (sh.TextSH, ''), - } - - # --- Signals - # ------------------------------------------------------------------------ - sig_focus_changed = Signal() - """ - This signal when the focus of the editor changes, either by a - `focusInEvent` or `focusOutEvent` event. - """ - - def __init__(self, parent=None): - super().__init__(parent) - - # Variables - self._linenumber_enabled = None - self._color_scheme = "spyder/dark" - self._language = None - self._blanks_enabled = None - self._scrollpastend_enabled = None - self._wrap_mode = None - self._highlight_current_line = None - self.supported_language = False - - # Widgets - self._highlighter = None - self.linenumberarea = LineNumberArea(self) - - # Widget setup - self.setObjectName(self.__class__.__name__ + str(id(self))) - self.update_linenumberarea_width(0) - self._apply_current_line_highlight() - - # Signals - self.blockCountChanged.connect(self.update_linenumberarea_width) - self.updateRequest.connect(self.update_linenumberarea) - self.cursorPositionChanged.connect(self._apply_current_line_highlight) - - # --- Private API - # ------------------------------------------------------------------------ - def _apply_color_scheme(self): - hl = self._highlighter - if hl is not None: - hl.setup_formats(self.font()) - if self._color_scheme is not None: - hl.set_color_scheme(self._color_scheme) - - self._set_palette(background=hl.get_background_color(), - foreground=hl.get_foreground_color()) - - def _set_palette(self, background, foreground): - style = ("QPlainTextEdit#%s {background: %s; color: %s;}" % - (self.objectName(), background.name(), foreground.name())) - self.setStyleSheet(style) - self.rehighlight() - - def _apply_current_line_highlight(self): - if self._highlighter and self._highlight_current_line: - extra_selections = [] - selection = QTextEdit.ExtraSelection() - line_color = self._highlighter.get_currentline_color() - selection.format.setBackground(line_color) - selection.format.setProperty(QTextFormat.FullWidthSelection, True) - selection.cursor = self.textCursor() - selection.cursor.clearSelection() - extra_selections.append(selection) - - self.setExtraSelections(extra_selections) - else: - self.setExtraSelections([]) - - # --- Qt Overrides - # ------------------------------------------------------------------------ - def focusInEvent(self, event): - self.sig_focus_changed.emit() - super().focusInEvent(event) - - def focusOutEvent(self, event): - self.sig_focus_changed.emit() - super().focusInEvent(event) - - def resizeEvent(self, event): - super().resizeEvent(event) - if self._linenumber_enabled: - cr = self.contentsRect() - self.linenumberarea.setGeometry( - QRect( - cr.left(), - cr.top(), - self.linenumberarea_width(), - cr.height(), - ) - ) - - # --- Public API - # ------------------------------------------------------------------------ - def setup_editor(self, - linenumbers=True, - color_scheme="spyder/dark", - language="py", - font=None, - show_blanks=False, - wrap=False, - highlight_current_line=True, - scroll_past_end=False): - """ - Setup editor options. - - Parameters - ---------- - color_scheme: str, optional - Default is "spyder/dark". - language: str, optional - Default is "py". - font: QFont or None - Default is None. - show_blanks: bool, optional - Default is False/ - wrap: bool, optional - Default is False. - highlight_current_line: bool, optional - Default is True. - scroll_past_end: bool, optional - Default is False - """ - if font: - self.set_font(font) - - self.set_highlight_current_line(highlight_current_line) - self.set_blanks_enabled(show_blanks) - self.toggle_line_numbers(linenumbers) - self.set_scrollpastend_enabled(scroll_past_end) - self.set_language(language) - self.set_color_scheme(color_scheme) - self.toggle_wrap_mode(wrap) - - def set_font(self, font): - """ - Set the editor font. - - Parameters - ---------- - font: QFont - Font to use. - """ - if font: - self.setFont(font) - self._apply_color_scheme() - - def set_color_scheme(self, color_scheme): - """ - Set the editor color scheme. - - Parameters - ---------- - color_scheme: str - Color scheme to use. - """ - self._color_scheme = color_scheme - self._apply_color_scheme() - - def set_language(self, language): - """ - Set current syntax highlighting to use `language`. - - Parameters - ---------- - language: str or None - Language name or known extensions. - """ - sh_class = sh.TextSH - language = str(language).lower() - self.supported_language = False - for (key, value) in LANGUAGE_EXTENSIONS.items(): - if language in (key.lower(), ) + value: - sh_class, __ = self.LANGUAGE_HIGHLIGHTERS[key] - self._language = key - self.supported_language = True - - self._highlighter = sh_class( - self.document(), self.font(), self._color_scheme) - self._apply_color_scheme() - - def toggle_line_numbers(self, state): - """ - Set visibility of line number area - - Parameters - ---------- - state: bool - Visible state of the line number area. - """ - - self._linenumber_enabled = state - self.linenumberarea.setVisible(state) - self.update_linenumberarea_width(()) - - def set_scrollpastend_enabled(self, state): - """ - Set scroll past end state. - - Parameters - ---------- - state: bool - Scroll past end state. - """ - self._scrollpastend_enabled = state - self.setCenterOnScroll(state) - self.setDocument(self.document()) - - def toggle_wrap_mode(self, state): - """ - Set line wrap.. - - Parameters - ---------- - state: bool - Wrap state. - """ - self.set_wrap_mode('word' if state else None) - - def set_wrap_mode(self, mode=None): - """ - Set line wrap mode. - - Parameters - ---------- - mode: str or None, optional - "word", or "character". Default is None. - """ - if mode == 'word': - wrap_mode = QTextOption.WrapAtWordBoundaryOrAnywhere - elif mode == 'character': - wrap_mode = QTextOption.WrapAnywhere - else: - wrap_mode = QTextOption.NoWrap - - self.setWordWrapMode(wrap_mode) - - def set_highlight_current_line(self, value): - """ - Set if the current line is highlighted. - - Parameters - ---------- - value: bool - The value of the current line highlight option. - """ - self._highlight_current_line = value - self._apply_current_line_highlight() - - def set_blanks_enabled(self, state): - """ - Show blank spaces. - - Parameters - ---------- - state: bool - Blank spaces visibility. - """ - self._blanks_enabled = state - option = self.document().defaultTextOption() - option.setFlags(option.flags() - | QTextOption.AddSpaceForLineAndParagraphSeparators) - - if self._blanks_enabled: - option.setFlags(option.flags() | QTextOption.ShowTabsAndSpaces) - else: - option.setFlags(option.flags() & ~QTextOption.ShowTabsAndSpaces) - - self.document().setDefaultTextOption(option) - - # Rehighlight to make the spaces less apparent. - self.rehighlight() - - # --- Line number area - # ------------------------------------------------------------------------ - def linenumberarea_paint_event(self, event): - """ - Paint the line number area. - """ - if self._linenumber_enabled: - painter = QPainter(self.linenumberarea) - painter.fillRect( - event.rect(), - self._highlighter.get_sideareas_color(), - ) - - block = self.firstVisibleBlock() - block_number = block.blockNumber() - top = round(self.blockBoundingGeometry(block).translated( - self.contentOffset()).top()) - bottom = top + round(self.blockBoundingRect(block).height()) - - font = self.font() - active_block = self.textCursor().block() - active_line_number = active_block.blockNumber() + 1 - - while block.isValid() and top <= event.rect().bottom(): - if block.isVisible() and bottom >= event.rect().top(): - number = block_number + 1 - - if number == active_line_number: - font.setWeight(font.Bold) - painter.setFont(font) - painter.setPen( - self._highlighter.get_foreground_color()) - else: - font.setWeight(font.Normal) - painter.setFont(font) - painter.setPen(QColor(Qt.darkGray)) - right_padding = self.linenumberarea._right_padding - painter.drawText( - 0, - top, - self.linenumberarea.width() - right_padding, - self.fontMetrics().height(), - Qt.AlignRight, str(number), - ) - - block = block.next() - top = bottom - bottom = top + round(self.blockBoundingRect(block).height()) - block_number += 1 - - def linenumberarea_width(self): - """ - Return the line number area width. - - Returns - ------- - int - Line number are width in pixels. - - Notes - ----- - If the line number area is disabled this will return zero. - """ - width = 0 - if self._linenumber_enabled: - digits = 1 - count = max(1, self.blockCount()) - while count >= 10: - count /= 10 - digits += 1 - - fm = self.fontMetrics() - width = (self.linenumberarea._left_padding - + self.linenumberarea._right_padding - + fm.width('9') * digits) - - return width - - def update_linenumberarea_width(self, new_block_count=None): - """ - Update the line number area width based on the number of blocks in - the document. - - Parameters - ---------- - new_block_count: int - The current number of blocks in the document. - """ - self.setViewportMargins(self.linenumberarea_width(), 0, 0, 0) - - def update_linenumberarea(self, rect, dy): - """ - Update scroll position of line number area. - """ - if self._linenumber_enabled: - if dy: - self.linenumberarea.scroll(0, dy) - else: - self.linenumberarea.update( - 0, rect.y(), self.linenumberarea.width(), rect.height()) - - if rect.contains(self.viewport().rect()): - self.update_linenumberarea_width(0) - - # --- Text and cursor handling - # ------------------------------------------------------------------------ - def set_selection(self, start, end): - """ - Set current text selection. - - Parameters - ---------- - start: int - Selection start position. - end: int - Selection end position. - """ - cursor = self.textCursor() - cursor.setPosition(start) - cursor.setPosition(end, QTextCursor.KeepAnchor) - self.setTextCursor(cursor) - - def stdkey_backspace(self): - if not self.has_selected_text(): - self.moveCursor(QTextCursor.PreviousCharacter, - QTextCursor.KeepAnchor) - self.remove_selected_text() - - def restrict_cursor_position(self, position_from, position_to): - """ - Restrict the cursor from being inside from and to positions. - - Parameters - ---------- - position_from: int - Selection start position. - position_to: int - Selection end position. - """ - position_from = self.get_position(position_from) - position_to = self.get_position(position_to) - cursor = self.textCursor() - cursor_position = cursor.position() - if cursor_position < position_from or cursor_position > position_to: - self.set_cursor_position(position_to) - - def truncate_selection(self, position_from): - """ - Restrict the cursor selection to start from the given position. - - Parameters - ---------- - position_from: int - Selection start position. - """ - position_from = self.get_position(position_from) - cursor = self.textCursor() - start, end = cursor.selectionStart(), cursor.selectionEnd() - if start < end: - start = max([position_from, start]) - else: - end = max([position_from, end]) - - self.set_selection(start, end) - - def set_text(self, text): - """ - Set `text` of the document. - - Parameters - ---------- - text: str - Text to set. - """ - self.setPlainText(text) - - def append(self, text): - """ - Add `text` to the end of the document. - - Parameters - ---------- - text: str - Text to append. - """ - cursor = self.textCursor() - cursor.movePosition(QTextCursor.End) - cursor.insertText(text) - - def get_visible_block_numbers(self): - """Get the first and last visible block numbers.""" - first = self.firstVisibleBlock().blockNumber() - bottom_right = QPoint(self.viewport().width() - 1, - self.viewport().height() - 1) - last = self.cursorForPosition(bottom_right).blockNumber() - return (first, last) - - # --- Syntax highlighter - # ------------------------------------------------------------------------ - def rehighlight(self): - """ - Reapply syntax highligthing to the document. - """ - if self._highlighter: - self._highlighter.rehighlight() - - -if __name__ == "__main__": - from spyder.utils.qthelpers import qapplication - - app = qapplication() - editor = SimpleCodeEditor() - editor.setup_editor(language="markdown") - editor.set_text("# Hello!") - editor.show() - app.exec_() +# -*- coding: utf-8 -*- +# ----------------------------------------------------------------------------- +# Copyright © Spyder Project Contributors +# +# Licensed under the terms of the MIT License +# (see spyder/__init__.py for details) +# ---------------------------------------------------------------------------- + +""" +Simple code editor with syntax highlighting and line number area. + +Adapted from: +https://doc.qt.io/qt-5/qtwidgets-widgets-codeeditor-example.html +""" + +# Third party imports +from qtpy.QtCore import QPoint, QRect, QSize, Qt, Signal +from qtpy.QtGui import QColor, QPainter, QTextCursor, QTextFormat, QTextOption +from qtpy.QtWidgets import QPlainTextEdit, QTextEdit, QWidget + +# Local imports +import spyder.utils.syntaxhighlighters as sh +from spyder.widgets.mixins import BaseEditMixin + + +# Constants +LANGUAGE_EXTENSIONS = { + 'Python': ('py', 'pyw', 'python', 'ipy'), + 'Cython': ('pyx', 'pxi', 'pxd'), + 'Enaml': ('enaml',), + 'Fortran77': ('f', 'for', 'f77'), + 'Fortran': ('f90', 'f95', 'f2k', 'f03', 'f08'), + 'Idl': ('pro',), + 'Diff': ('diff', 'patch', 'rej'), + 'GetText': ('po', 'pot'), + 'Nsis': ('nsi', 'nsh'), + 'Html': ('htm', 'html'), + 'Cpp': ('c', 'cc', 'cpp', 'cxx', 'h', 'hh', 'hpp', 'hxx'), + 'OpenCL': ('cl',), + 'Yaml': ('yaml', 'yml'), + 'Markdown': ('md', 'mdw'), + # Every other language + 'None': ('', ), +} + + +class LineNumberArea(QWidget): + """ + Adapted from: + https://doc.qt.io/qt-5/qtwidgets-widgets-codeeditor-example.html + """ + + def __init__(self, code_editor=None): + super().__init__(code_editor) + + self._editor = code_editor + self._left_padding = 6 # Pixels + self._right_padding = 3 # Pixels + + # --- Qt overrides + # ------------------------------------------------------------------------ + def sizeHint(self): + return QSize(self._editor.linenumberarea_width(), 0) + + def paintEvent(self, event): + self._editor.linenumberarea_paint_event(event) + + +class SimpleCodeEditor(QPlainTextEdit, BaseEditMixin): + """Simple editor with highlight features.""" + + LANGUAGE_HIGHLIGHTERS = { + 'Python': (sh.PythonSH, '#'), + 'Cython': (sh.CythonSH, '#'), + 'Fortran77': (sh.Fortran77SH, 'c'), + 'Fortran': (sh.FortranSH, '!'), + 'Idl': (sh.IdlSH, ';'), + 'Diff': (sh.DiffSH, ''), + 'GetText': (sh.GetTextSH, '#'), + 'Nsis': (sh.NsisSH, '#'), + 'Html': (sh.HtmlSH, ''), + 'Yaml': (sh.YamlSH, '#'), + 'Cpp': (sh.CppSH, '//'), + 'OpenCL': (sh.OpenCLSH, '//'), + 'Enaml': (sh.EnamlSH, '#'), + 'Markdown': (sh.MarkdownSH, '#'), + # Every other language + 'None': (sh.TextSH, ''), + } + + # --- Signals + # ------------------------------------------------------------------------ + sig_focus_changed = Signal() + """ + This signal when the focus of the editor changes, either by a + `focusInEvent` or `focusOutEvent` event. + """ + + def __init__(self, parent=None): + super().__init__(parent) + + # Variables + self._linenumber_enabled = None + self._color_scheme = "spyder/dark" + self._language = None + self._blanks_enabled = None + self._scrollpastend_enabled = None + self._wrap_mode = None + self._highlight_current_line = None + self.supported_language = False + + # Widgets + self._highlighter = None + self.linenumberarea = LineNumberArea(self) + + # Widget setup + self.setObjectName(self.__class__.__name__ + str(id(self))) + self.update_linenumberarea_width(0) + self._apply_current_line_highlight() + + # Signals + self.blockCountChanged.connect(self.update_linenumberarea_width) + self.updateRequest.connect(self.update_linenumberarea) + self.cursorPositionChanged.connect(self._apply_current_line_highlight) + + # --- Private API + # ------------------------------------------------------------------------ + def _apply_color_scheme(self): + hl = self._highlighter + if hl is not None: + hl.setup_formats(self.font()) + if self._color_scheme is not None: + hl.set_color_scheme(self._color_scheme) + + self._set_palette(background=hl.get_background_color(), + foreground=hl.get_foreground_color()) + + def _set_palette(self, background, foreground): + style = ("QPlainTextEdit#%s {background: %s; color: %s;}" % + (self.objectName(), background.name(), foreground.name())) + self.setStyleSheet(style) + self.rehighlight() + + def _apply_current_line_highlight(self): + if self._highlighter and self._highlight_current_line: + extra_selections = [] + selection = QTextEdit.ExtraSelection() + line_color = self._highlighter.get_currentline_color() + selection.format.setBackground(line_color) + selection.format.setProperty(QTextFormat.FullWidthSelection, True) + selection.cursor = self.textCursor() + selection.cursor.clearSelection() + extra_selections.append(selection) + + self.setExtraSelections(extra_selections) + else: + self.setExtraSelections([]) + + # --- Qt Overrides + # ------------------------------------------------------------------------ + def focusInEvent(self, event): + self.sig_focus_changed.emit() + super().focusInEvent(event) + + def focusOutEvent(self, event): + self.sig_focus_changed.emit() + super().focusInEvent(event) + + def resizeEvent(self, event): + super().resizeEvent(event) + if self._linenumber_enabled: + cr = self.contentsRect() + self.linenumberarea.setGeometry( + QRect( + cr.left(), + cr.top(), + self.linenumberarea_width(), + cr.height(), + ) + ) + + # --- Public API + # ------------------------------------------------------------------------ + def setup_editor(self, + linenumbers=True, + color_scheme="spyder/dark", + language="py", + font=None, + show_blanks=False, + wrap=False, + highlight_current_line=True, + scroll_past_end=False): + """ + Setup editor options. + + Parameters + ---------- + color_scheme: str, optional + Default is "spyder/dark". + language: str, optional + Default is "py". + font: QFont or None + Default is None. + show_blanks: bool, optional + Default is False/ + wrap: bool, optional + Default is False. + highlight_current_line: bool, optional + Default is True. + scroll_past_end: bool, optional + Default is False + """ + if font: + self.set_font(font) + + self.set_highlight_current_line(highlight_current_line) + self.set_blanks_enabled(show_blanks) + self.toggle_line_numbers(linenumbers) + self.set_scrollpastend_enabled(scroll_past_end) + self.set_language(language) + self.set_color_scheme(color_scheme) + self.toggle_wrap_mode(wrap) + + def set_font(self, font): + """ + Set the editor font. + + Parameters + ---------- + font: QFont + Font to use. + """ + if font: + self.setFont(font) + self._apply_color_scheme() + + def set_color_scheme(self, color_scheme): + """ + Set the editor color scheme. + + Parameters + ---------- + color_scheme: str + Color scheme to use. + """ + self._color_scheme = color_scheme + self._apply_color_scheme() + + def set_language(self, language): + """ + Set current syntax highlighting to use `language`. + + Parameters + ---------- + language: str or None + Language name or known extensions. + """ + sh_class = sh.TextSH + language = str(language).lower() + self.supported_language = False + for (key, value) in LANGUAGE_EXTENSIONS.items(): + if language in (key.lower(), ) + value: + sh_class, __ = self.LANGUAGE_HIGHLIGHTERS[key] + self._language = key + self.supported_language = True + + self._highlighter = sh_class( + self.document(), self.font(), self._color_scheme) + self._apply_color_scheme() + + def toggle_line_numbers(self, state): + """ + Set visibility of line number area + + Parameters + ---------- + state: bool + Visible state of the line number area. + """ + + self._linenumber_enabled = state + self.linenumberarea.setVisible(state) + self.update_linenumberarea_width(()) + + def set_scrollpastend_enabled(self, state): + """ + Set scroll past end state. + + Parameters + ---------- + state: bool + Scroll past end state. + """ + self._scrollpastend_enabled = state + self.setCenterOnScroll(state) + self.setDocument(self.document()) + + def toggle_wrap_mode(self, state): + """ + Set line wrap.. + + Parameters + ---------- + state: bool + Wrap state. + """ + self.set_wrap_mode('word' if state else None) + + def set_wrap_mode(self, mode=None): + """ + Set line wrap mode. + + Parameters + ---------- + mode: str or None, optional + "word", or "character". Default is None. + """ + if mode == 'word': + wrap_mode = QTextOption.WrapAtWordBoundaryOrAnywhere + elif mode == 'character': + wrap_mode = QTextOption.WrapAnywhere + else: + wrap_mode = QTextOption.NoWrap + + self.setWordWrapMode(wrap_mode) + + def set_highlight_current_line(self, value): + """ + Set if the current line is highlighted. + + Parameters + ---------- + value: bool + The value of the current line highlight option. + """ + self._highlight_current_line = value + self._apply_current_line_highlight() + + def set_blanks_enabled(self, state): + """ + Show blank spaces. + + Parameters + ---------- + state: bool + Blank spaces visibility. + """ + self._blanks_enabled = state + option = self.document().defaultTextOption() + option.setFlags(option.flags() + | QTextOption.AddSpaceForLineAndParagraphSeparators) + + if self._blanks_enabled: + option.setFlags(option.flags() | QTextOption.ShowTabsAndSpaces) + else: + option.setFlags(option.flags() & ~QTextOption.ShowTabsAndSpaces) + + self.document().setDefaultTextOption(option) + + # Rehighlight to make the spaces less apparent. + self.rehighlight() + + # --- Line number area + # ------------------------------------------------------------------------ + def linenumberarea_paint_event(self, event): + """ + Paint the line number area. + """ + if self._linenumber_enabled: + painter = QPainter(self.linenumberarea) + painter.fillRect( + event.rect(), + self._highlighter.get_sideareas_color(), + ) + + block = self.firstVisibleBlock() + block_number = block.blockNumber() + top = round(self.blockBoundingGeometry(block).translated( + self.contentOffset()).top()) + bottom = top + round(self.blockBoundingRect(block).height()) + + font = self.font() + active_block = self.textCursor().block() + active_line_number = active_block.blockNumber() + 1 + + while block.isValid() and top <= event.rect().bottom(): + if block.isVisible() and bottom >= event.rect().top(): + number = block_number + 1 + + if number == active_line_number: + font.setWeight(font.Bold) + painter.setFont(font) + painter.setPen( + self._highlighter.get_foreground_color()) + else: + font.setWeight(font.Normal) + painter.setFont(font) + painter.setPen(QColor(Qt.darkGray)) + right_padding = self.linenumberarea._right_padding + painter.drawText( + 0, + top, + self.linenumberarea.width() - right_padding, + self.fontMetrics().height(), + Qt.AlignRight, str(number), + ) + + block = block.next() + top = bottom + bottom = top + round(self.blockBoundingRect(block).height()) + block_number += 1 + + def linenumberarea_width(self): + """ + Return the line number area width. + + Returns + ------- + int + Line number are width in pixels. + + Notes + ----- + If the line number area is disabled this will return zero. + """ + width = 0 + if self._linenumber_enabled: + digits = 1 + count = max(1, self.blockCount()) + while count >= 10: + count /= 10 + digits += 1 + + fm = self.fontMetrics() + width = (self.linenumberarea._left_padding + + self.linenumberarea._right_padding + + fm.width('9') * digits) + + return width + + def update_linenumberarea_width(self, new_block_count=None): + """ + Update the line number area width based on the number of blocks in + the document. + + Parameters + ---------- + new_block_count: int + The current number of blocks in the document. + """ + self.setViewportMargins(self.linenumberarea_width(), 0, 0, 0) + + def update_linenumberarea(self, rect, dy): + """ + Update scroll position of line number area. + """ + if self._linenumber_enabled: + if dy: + self.linenumberarea.scroll(0, dy) + else: + self.linenumberarea.update( + 0, rect.y(), self.linenumberarea.width(), rect.height()) + + if rect.contains(self.viewport().rect()): + self.update_linenumberarea_width(0) + + # --- Text and cursor handling + # ------------------------------------------------------------------------ + def set_selection(self, start, end): + """ + Set current text selection. + + Parameters + ---------- + start: int + Selection start position. + end: int + Selection end position. + """ + cursor = self.textCursor() + cursor.setPosition(start) + cursor.setPosition(end, QTextCursor.KeepAnchor) + self.setTextCursor(cursor) + + def stdkey_backspace(self): + if not self.has_selected_text(): + self.moveCursor(QTextCursor.PreviousCharacter, + QTextCursor.KeepAnchor) + self.remove_selected_text() + + def restrict_cursor_position(self, position_from, position_to): + """ + Restrict the cursor from being inside from and to positions. + + Parameters + ---------- + position_from: int + Selection start position. + position_to: int + Selection end position. + """ + position_from = self.get_position(position_from) + position_to = self.get_position(position_to) + cursor = self.textCursor() + cursor_position = cursor.position() + if cursor_position < position_from or cursor_position > position_to: + self.set_cursor_position(position_to) + + def truncate_selection(self, position_from): + """ + Restrict the cursor selection to start from the given position. + + Parameters + ---------- + position_from: int + Selection start position. + """ + position_from = self.get_position(position_from) + cursor = self.textCursor() + start, end = cursor.selectionStart(), cursor.selectionEnd() + if start < end: + start = max([position_from, start]) + else: + end = max([position_from, end]) + + self.set_selection(start, end) + + def set_text(self, text): + """ + Set `text` of the document. + + Parameters + ---------- + text: str + Text to set. + """ + self.setPlainText(text) + + def append(self, text): + """ + Add `text` to the end of the document. + + Parameters + ---------- + text: str + Text to append. + """ + cursor = self.textCursor() + cursor.movePosition(QTextCursor.End) + cursor.insertText(text) + + def get_visible_block_numbers(self): + """Get the first and last visible block numbers.""" + first = self.firstVisibleBlock().blockNumber() + bottom_right = QPoint(self.viewport().width() - 1, + self.viewport().height() - 1) + last = self.cursorForPosition(bottom_right).blockNumber() + return (first, last) + + # --- Syntax highlighter + # ------------------------------------------------------------------------ + def rehighlight(self): + """ + Reapply syntax highligthing to the document. + """ + if self._highlighter: + self._highlighter.rehighlight() + + +if __name__ == "__main__": + from spyder.utils.qthelpers import qapplication + + app = qapplication() + editor = SimpleCodeEditor() + editor.setup_editor(language="markdown") + editor.set_text("# Hello!") + editor.show() + app.exec_() diff --git a/spyder/widgets/tabs.py b/spyder/widgets/tabs.py index b3facaf0b1a..5a887038182 100644 --- a/spyder/widgets/tabs.py +++ b/spyder/widgets/tabs.py @@ -1,506 +1,506 @@ -# -*- coding: utf-8 -*- -# -# Copyright © Spyder Project Contributors -# Licensed under the terms of the MIT License -# (see spyder/__init__.py for details) - -"""Tabs widget""" - -# pylint: disable=C0103 -# pylint: disable=R0903 -# pylint: disable=R0911 -# pylint: disable=R0201 - -# Standard library imports -import os -import os.path as osp -import sys - -# Third party imports -from qtpy import PYQT5 -from qtpy.QtCore import QEvent, QPoint, Qt, Signal, Slot -from qtpy.QtWidgets import (QHBoxLayout, QMenu, QTabBar, - QTabWidget, QWidget, QLineEdit) - -# Local imports -from spyder.config.base import _ -from spyder.config.manager import CONF -from spyder.py3compat import to_text_string -from spyder.utils.icon_manager import ima -from spyder.utils.misc import get_common_path -from spyder.utils.qthelpers import (add_actions, create_action, - create_toolbutton) -from spyder.utils.stylesheet import PANES_TABBAR_STYLESHEET - - -class EditTabNamePopup(QLineEdit): - """Popup on top of the tab to edit its name.""" - - def __init__(self, parent, split_char, split_index): - """Popup on top of the tab to edit its name.""" - - # Variables - # Parent (main) - self.main = parent if parent is not None else self.parent() - self.split_char = split_char - self.split_index = split_index - - # Track which tab is being edited - self.tab_index = None - - # Widget setup - QLineEdit.__init__(self, parent=parent) - - # Slot to handle tab name update - self.editingFinished.connect(self.edit_finished) - - # Even filter to catch clicks and ESC key - self.installEventFilter(self) - - # Clean borders and no shadow to blend with tab - if PYQT5: - self.setWindowFlags( - Qt.Popup | - Qt.FramelessWindowHint | - Qt.NoDropShadowWindowHint - ) - else: - self.setWindowFlags( - Qt.Popup | - Qt.FramelessWindowHint - ) - self.setFrame(False) - - # Align with tab name - self.setTextMargins(9, 0, 0, 0) - - def eventFilter(self, widget, event): - """Catch clicks outside the object and ESC key press.""" - if ((event.type() == QEvent.MouseButtonPress and - not self.geometry().contains(event.globalPos())) or - (event.type() == QEvent.KeyPress and - event.key() == Qt.Key_Escape)): - # Exits editing - self.hide() - return True - - # Event is not interessant, raise to parent - return QLineEdit.eventFilter(self, widget, event) - - def edit_tab(self, index): - """Activate the edit tab.""" - - # Sets focus, shows cursor - self.setFocus() - - # Updates tab index - self.tab_index = index - - # Gets tab size and shrinks to avoid overlapping tab borders - rect = self.main.tabRect(index) - rect.adjust(1, 1, -2, -1) - - # Sets size - self.setFixedSize(rect.size()) - - # Places on top of the tab - self.move(self.main.mapToGlobal(rect.topLeft())) - - # Copies tab name and selects all - text = self.main.tabText(index) - text = text.replace(u'&', u'') - if self.split_char: - text = text.split(self.split_char)[self.split_index] - - self.setText(text) - self.selectAll() - - if not self.isVisible(): - # Makes editor visible - self.show() - - def edit_finished(self): - """On clean exit, update tab name.""" - # Hides editor - self.hide() - - if isinstance(self.tab_index, int) and self.tab_index >= 0: - # We are editing a valid tab, update name - tab_text = to_text_string(self.text()) - self.main.setTabText(self.tab_index, tab_text) - self.main.sig_name_changed.emit(tab_text) - - -class TabBar(QTabBar): - """Tabs base class with drag and drop support""" - sig_move_tab = Signal((int, int), (str, int, int)) - sig_name_changed = Signal(str) - - def __init__(self, parent, ancestor, rename_tabs=False, split_char='', - split_index=0): - QTabBar.__init__(self, parent) - self.ancestor = ancestor - self.setObjectName('pane-tabbar') - - # Dragging tabs - self.__drag_start_pos = QPoint() - self.setAcceptDrops(True) - self.setUsesScrollButtons(True) - self.setMovable(True) - - # Tab name editor - self.rename_tabs = rename_tabs - if self.rename_tabs: - # Creates tab name editor - self.tab_name_editor = EditTabNamePopup(self, split_char, - split_index) - else: - self.tab_name_editor = None - - def mousePressEvent(self, event): - """Reimplement Qt method""" - if event.button() == Qt.LeftButton: - self.__drag_start_pos = QPoint(event.pos()) - QTabBar.mousePressEvent(self, event) - - def mouseMoveEvent(self, event): - """Override Qt method""" - # FIXME: This was added by Pierre presumably to move tabs - # between plugins, but righit now it's breaking the regular - # Qt drag behavior for tabs, so we're commenting it for - # now - #if event.buttons() == Qt.MouseButtons(Qt.LeftButton) and \ - # (event.pos() - self.__drag_start_pos).manhattanLength() > \ - # QApplication.startDragDistance(): - # drag = QDrag(self) - # mimeData = QMimeData()# - - # ancestor_id = to_text_string(id(self.ancestor)) - # parent_widget_id = to_text_string(id(self.parentWidget())) - # self_id = to_text_string(id(self)) - # source_index = to_text_string(self.tabAt(self.__drag_start_pos)) - - # mimeData.setData("parent-id", to_binary_string(ancestor_id)) - # mimeData.setData("tabwidget-id", - # to_binary_string(parent_widget_id)) - # mimeData.setData("tabbar-id", to_binary_string(self_id)) - # mimeData.setData("source-index", to_binary_string(source_index)) - - # drag.setMimeData(mimeData) - # drag.exec_() - QTabBar.mouseMoveEvent(self, event) - - def dragEnterEvent(self, event): - """Override Qt method""" - mimeData = event.mimeData() - formats = list(mimeData.formats()) - - if "parent-id" in formats and \ - int(mimeData.data("parent-id")) == id(self.ancestor): - event.acceptProposedAction() - - QTabBar.dragEnterEvent(self, event) - - def dropEvent(self, event): - """Override Qt method""" - mimeData = event.mimeData() - index_from = int(mimeData.data("source-index")) - index_to = self.tabAt(event.pos()) - if index_to == -1: - index_to = self.count() - if int(mimeData.data("tabbar-id")) != id(self): - tabwidget_from = to_text_string(mimeData.data("tabwidget-id")) - - # We pass self object ID as a QString, because otherwise it would - # depend on the platform: long for 64bit, int for 32bit. Replacing - # by long all the time is not working on some 32bit platforms. - # See spyder-ide/spyder#1094 and spyder-ide/spyder#1098. - self.sig_move_tab[(str, int, int)].emit(tabwidget_from, index_from, - index_to) - event.acceptProposedAction() - elif index_from != index_to: - self.sig_move_tab.emit(index_from, index_to) - event.acceptProposedAction() - QTabBar.dropEvent(self, event) - - def mouseDoubleClickEvent(self, event): - """Override Qt method to trigger the tab name editor.""" - if self.rename_tabs is True and \ - event.buttons() == Qt.MouseButtons(Qt.LeftButton): - # Tab index - index = self.tabAt(event.pos()) - if index >= 0: - # Tab is valid, call tab name editor - self.tab_name_editor.edit_tab(index) - else: - # Event is not interesting, raise to parent - QTabBar.mouseDoubleClickEvent(self, event) - - -class BaseTabs(QTabWidget): - """TabWidget with context menu and corner widgets""" - sig_close_tab = Signal(int) - - def __init__(self, parent, actions=None, menu=None, - corner_widgets=None, menu_use_tooltips=False): - QTabWidget.__init__(self, parent) - self.setUsesScrollButtons(True) - self.tabBar().setObjectName('pane-tabbar') - - self.corner_widgets = {} - self.menu_use_tooltips = menu_use_tooltips - - if menu is None: - self.menu = QMenu(self) - if actions: - add_actions(self.menu, actions) - else: - self.menu = menu - - self.setStyleSheet(str(PANES_TABBAR_STYLESHEET)) - - # Corner widgets - if corner_widgets is None: - corner_widgets = {} - corner_widgets.setdefault(Qt.TopLeftCorner, []) - corner_widgets.setdefault(Qt.TopRightCorner, []) - - self.browse_button = create_toolbutton( - self, icon=ima.icon('browse_tab'), tip=_("Browse tabs")) - self.browse_button.setStyleSheet(str(PANES_TABBAR_STYLESHEET)) - - self.browse_tabs_menu = QMenu(self) - self.browse_tabs_menu.setObjectName('checkbox-padding') - self.browse_button.setMenu(self.browse_tabs_menu) - self.browse_button.setPopupMode(self.browse_button.InstantPopup) - self.browse_tabs_menu.aboutToShow.connect(self.update_browse_tabs_menu) - corner_widgets[Qt.TopLeftCorner] += [self.browse_button] - - self.set_corner_widgets(corner_widgets) - - def update_browse_tabs_menu(self): - """Update browse tabs menu""" - self.browse_tabs_menu.clear() - names = [] - dirnames = [] - for index in range(self.count()): - if self.menu_use_tooltips: - text = to_text_string(self.tabToolTip(index)) - else: - text = to_text_string(self.tabText(index)) - names.append(text) - if osp.isfile(text): - # Testing if tab names are filenames - dirnames.append(osp.dirname(text)) - offset = None - - # If tab names are all filenames, removing common path: - if len(names) == len(dirnames): - common = get_common_path(dirnames) - if common is None: - offset = None - else: - offset = len(common)+1 - if offset <= 3: - # Common path is not a path but a drive letter... - offset = None - - for index, text in enumerate(names): - tab_action = create_action(self, text[offset:], - icon=self.tabIcon(index), - toggled=lambda state, index=index: - self.setCurrentIndex(index), - tip=self.tabToolTip(index)) - tab_action.setChecked(index == self.currentIndex()) - self.browse_tabs_menu.addAction(tab_action) - - def set_corner_widgets(self, corner_widgets): - """ - Set tabs corner widgets - corner_widgets: dictionary of (corner, widgets) - corner: Qt.TopLeftCorner or Qt.TopRightCorner - widgets: list of widgets (may contains integers to add spacings) - """ - assert isinstance(corner_widgets, dict) - assert all(key in (Qt.TopLeftCorner, Qt.TopRightCorner) - for key in corner_widgets) - self.corner_widgets.update(corner_widgets) - for corner, widgets in list(self.corner_widgets.items()): - cwidget = QWidget() - cwidget.hide() - - # This removes some white dots in our tabs (not all but most). - # See spyder-ide/spyder#15081 - cwidget.setObjectName('corner-widget') - cwidget.setStyleSheet( - "QWidget#corner-widget {border-radius: '0px'}") - - prev_widget = self.cornerWidget(corner) - if prev_widget: - prev_widget.close() - self.setCornerWidget(cwidget, corner) - clayout = QHBoxLayout() - clayout.setContentsMargins(0, 0, 0, 0) - for widget in widgets: - if isinstance(widget, int): - clayout.addSpacing(widget) - else: - clayout.addWidget(widget) - cwidget.setLayout(clayout) - cwidget.show() - - def add_corner_widgets(self, widgets, corner=Qt.TopRightCorner): - self.set_corner_widgets({corner: - self.corner_widgets.get(corner, [])+widgets}) - - def get_offset_pos(self, event): - """ - Add offset to position event to capture the mouse cursor - inside a tab. - """ - # This is necessary because event.pos() is the position in this - # widget, not in the tabBar. see spyder-ide/spyder#12617 - tb = self.tabBar() - point = tb.mapFromGlobal(event.globalPos()) - return tb.tabAt(point) - - def contextMenuEvent(self, event): - """Override Qt method""" - index = self.get_offset_pos(event) - self.setCurrentIndex(index) - if self.menu: - self.menu.popup(event.globalPos()) - - def mousePressEvent(self, event): - """Override Qt method""" - if event.button() == Qt.MidButton: - index = self.get_offset_pos(event) - if index >= 0: - self.sig_close_tab.emit(index) - event.accept() - return - QTabWidget.mousePressEvent(self, event) - - def keyPressEvent(self, event): - """Override Qt method""" - ctrl = event.modifiers() & Qt.ControlModifier - key = event.key() - handled = False - if ctrl and self.count() > 0: - index = self.currentIndex() - if key == Qt.Key_PageUp: - if index > 0: - self.setCurrentIndex(index - 1) - else: - self.setCurrentIndex(self.count() - 1) - handled = True - elif key == Qt.Key_PageDown: - if index < self.count() - 1: - self.setCurrentIndex(index + 1) - else: - self.setCurrentIndex(0) - handled = True - if not handled: - QTabWidget.keyPressEvent(self, event) - - def tab_navigate(self, delta=1): - """Ctrl+Tab""" - if delta > 0 and self.currentIndex() == self.count()-1: - index = delta-1 - elif delta < 0 and self.currentIndex() == 0: - index = self.count()+delta - else: - index = self.currentIndex()+delta - self.setCurrentIndex(index) - - def set_close_function(self, func): - """Setting Tabs close function - None -> tabs are not closable""" - state = func is not None - if state: - self.sig_close_tab.connect(func) - try: - # Assuming Qt >= 4.5 - QTabWidget.setTabsClosable(self, state) - self.tabCloseRequested.connect(func) - except AttributeError: - # Workaround for Qt < 4.5 - close_button = create_toolbutton(self, triggered=func, - icon=ima.icon('fileclose'), - tip=_("Close current tab")) - self.setCornerWidget(close_button if state else None) - - -class Tabs(BaseTabs): - """BaseTabs widget with movable tabs and tab navigation shortcuts.""" - # Signals - move_data = Signal(int, int) - move_tab_finished = Signal() - sig_move_tab = Signal(str, str, int, int) - - def __init__(self, parent, actions=None, menu=None, - corner_widgets=None, menu_use_tooltips=False, - rename_tabs=False, split_char='', - split_index=0): - BaseTabs.__init__(self, parent, actions, menu, - corner_widgets, menu_use_tooltips) - tab_bar = TabBar(self, parent, - rename_tabs=rename_tabs, - split_char=split_char, - split_index=split_index) - tab_bar.sig_move_tab.connect(self.move_tab) - tab_bar.sig_move_tab[(str, int, int)].connect( - self.move_tab_from_another_tabwidget) - self.setTabBar(tab_bar) - - CONF.config_shortcut( - lambda: self.tab_navigate(1), - context='editor', - name='go to next file', - parent=parent) - - CONF.config_shortcut( - lambda: self.tab_navigate(-1), - context='editor', - name='go to previous file', - parent=parent) - - CONF.config_shortcut( - lambda: self.sig_close_tab.emit(self.currentIndex()), - context='editor', - name='close file 1', - parent=parent) - - CONF.config_shortcut( - lambda: self.sig_close_tab.emit(self.currentIndex()), - context='editor', - name='close file 2', - parent=parent) - - @Slot(int, int) - def move_tab(self, index_from, index_to): - """Move tab inside a tabwidget""" - self.move_data.emit(index_from, index_to) - - tip, text = self.tabToolTip(index_from), self.tabText(index_from) - icon, widget = self.tabIcon(index_from), self.widget(index_from) - current_widget = self.currentWidget() - - self.removeTab(index_from) - self.insertTab(index_to, widget, icon, text) - self.setTabToolTip(index_to, tip) - - self.setCurrentWidget(current_widget) - self.move_tab_finished.emit() - - @Slot(str, int, int) - def move_tab_from_another_tabwidget(self, tabwidget_from, - index_from, index_to): - """Move tab from a tabwidget to another""" - - # We pass self object IDs as QString objs, because otherwise it would - # depend on the platform: long for 64bit, int for 32bit. Replacing - # by long all the time is not working on some 32bit platforms. - # See spyder-ide/spyder#1094 and spyder-ide/spyder#1098. - self.sig_move_tab.emit(tabwidget_from, to_text_string(id(self)), - index_from, index_to) +# -*- coding: utf-8 -*- +# +# Copyright © Spyder Project Contributors +# Licensed under the terms of the MIT License +# (see spyder/__init__.py for details) + +"""Tabs widget""" + +# pylint: disable=C0103 +# pylint: disable=R0903 +# pylint: disable=R0911 +# pylint: disable=R0201 + +# Standard library imports +import os +import os.path as osp +import sys + +# Third party imports +from qtpy import PYQT5 +from qtpy.QtCore import QEvent, QPoint, Qt, Signal, Slot +from qtpy.QtWidgets import (QHBoxLayout, QMenu, QTabBar, + QTabWidget, QWidget, QLineEdit) + +# Local imports +from spyder.config.base import _ +from spyder.config.manager import CONF +from spyder.py3compat import to_text_string +from spyder.utils.icon_manager import ima +from spyder.utils.misc import get_common_path +from spyder.utils.qthelpers import (add_actions, create_action, + create_toolbutton) +from spyder.utils.stylesheet import PANES_TABBAR_STYLESHEET + + +class EditTabNamePopup(QLineEdit): + """Popup on top of the tab to edit its name.""" + + def __init__(self, parent, split_char, split_index): + """Popup on top of the tab to edit its name.""" + + # Variables + # Parent (main) + self.main = parent if parent is not None else self.parent() + self.split_char = split_char + self.split_index = split_index + + # Track which tab is being edited + self.tab_index = None + + # Widget setup + QLineEdit.__init__(self, parent=parent) + + # Slot to handle tab name update + self.editingFinished.connect(self.edit_finished) + + # Even filter to catch clicks and ESC key + self.installEventFilter(self) + + # Clean borders and no shadow to blend with tab + if PYQT5: + self.setWindowFlags( + Qt.Popup | + Qt.FramelessWindowHint | + Qt.NoDropShadowWindowHint + ) + else: + self.setWindowFlags( + Qt.Popup | + Qt.FramelessWindowHint + ) + self.setFrame(False) + + # Align with tab name + self.setTextMargins(9, 0, 0, 0) + + def eventFilter(self, widget, event): + """Catch clicks outside the object and ESC key press.""" + if ((event.type() == QEvent.MouseButtonPress and + not self.geometry().contains(event.globalPos())) or + (event.type() == QEvent.KeyPress and + event.key() == Qt.Key_Escape)): + # Exits editing + self.hide() + return True + + # Event is not interessant, raise to parent + return QLineEdit.eventFilter(self, widget, event) + + def edit_tab(self, index): + """Activate the edit tab.""" + + # Sets focus, shows cursor + self.setFocus() + + # Updates tab index + self.tab_index = index + + # Gets tab size and shrinks to avoid overlapping tab borders + rect = self.main.tabRect(index) + rect.adjust(1, 1, -2, -1) + + # Sets size + self.setFixedSize(rect.size()) + + # Places on top of the tab + self.move(self.main.mapToGlobal(rect.topLeft())) + + # Copies tab name and selects all + text = self.main.tabText(index) + text = text.replace(u'&', u'') + if self.split_char: + text = text.split(self.split_char)[self.split_index] + + self.setText(text) + self.selectAll() + + if not self.isVisible(): + # Makes editor visible + self.show() + + def edit_finished(self): + """On clean exit, update tab name.""" + # Hides editor + self.hide() + + if isinstance(self.tab_index, int) and self.tab_index >= 0: + # We are editing a valid tab, update name + tab_text = to_text_string(self.text()) + self.main.setTabText(self.tab_index, tab_text) + self.main.sig_name_changed.emit(tab_text) + + +class TabBar(QTabBar): + """Tabs base class with drag and drop support""" + sig_move_tab = Signal((int, int), (str, int, int)) + sig_name_changed = Signal(str) + + def __init__(self, parent, ancestor, rename_tabs=False, split_char='', + split_index=0): + QTabBar.__init__(self, parent) + self.ancestor = ancestor + self.setObjectName('pane-tabbar') + + # Dragging tabs + self.__drag_start_pos = QPoint() + self.setAcceptDrops(True) + self.setUsesScrollButtons(True) + self.setMovable(True) + + # Tab name editor + self.rename_tabs = rename_tabs + if self.rename_tabs: + # Creates tab name editor + self.tab_name_editor = EditTabNamePopup(self, split_char, + split_index) + else: + self.tab_name_editor = None + + def mousePressEvent(self, event): + """Reimplement Qt method""" + if event.button() == Qt.LeftButton: + self.__drag_start_pos = QPoint(event.pos()) + QTabBar.mousePressEvent(self, event) + + def mouseMoveEvent(self, event): + """Override Qt method""" + # FIXME: This was added by Pierre presumably to move tabs + # between plugins, but righit now it's breaking the regular + # Qt drag behavior for tabs, so we're commenting it for + # now + #if event.buttons() == Qt.MouseButtons(Qt.LeftButton) and \ + # (event.pos() - self.__drag_start_pos).manhattanLength() > \ + # QApplication.startDragDistance(): + # drag = QDrag(self) + # mimeData = QMimeData()# + + # ancestor_id = to_text_string(id(self.ancestor)) + # parent_widget_id = to_text_string(id(self.parentWidget())) + # self_id = to_text_string(id(self)) + # source_index = to_text_string(self.tabAt(self.__drag_start_pos)) + + # mimeData.setData("parent-id", to_binary_string(ancestor_id)) + # mimeData.setData("tabwidget-id", + # to_binary_string(parent_widget_id)) + # mimeData.setData("tabbar-id", to_binary_string(self_id)) + # mimeData.setData("source-index", to_binary_string(source_index)) + + # drag.setMimeData(mimeData) + # drag.exec_() + QTabBar.mouseMoveEvent(self, event) + + def dragEnterEvent(self, event): + """Override Qt method""" + mimeData = event.mimeData() + formats = list(mimeData.formats()) + + if "parent-id" in formats and \ + int(mimeData.data("parent-id")) == id(self.ancestor): + event.acceptProposedAction() + + QTabBar.dragEnterEvent(self, event) + + def dropEvent(self, event): + """Override Qt method""" + mimeData = event.mimeData() + index_from = int(mimeData.data("source-index")) + index_to = self.tabAt(event.pos()) + if index_to == -1: + index_to = self.count() + if int(mimeData.data("tabbar-id")) != id(self): + tabwidget_from = to_text_string(mimeData.data("tabwidget-id")) + + # We pass self object ID as a QString, because otherwise it would + # depend on the platform: long for 64bit, int for 32bit. Replacing + # by long all the time is not working on some 32bit platforms. + # See spyder-ide/spyder#1094 and spyder-ide/spyder#1098. + self.sig_move_tab[(str, int, int)].emit(tabwidget_from, index_from, + index_to) + event.acceptProposedAction() + elif index_from != index_to: + self.sig_move_tab.emit(index_from, index_to) + event.acceptProposedAction() + QTabBar.dropEvent(self, event) + + def mouseDoubleClickEvent(self, event): + """Override Qt method to trigger the tab name editor.""" + if self.rename_tabs is True and \ + event.buttons() == Qt.MouseButtons(Qt.LeftButton): + # Tab index + index = self.tabAt(event.pos()) + if index >= 0: + # Tab is valid, call tab name editor + self.tab_name_editor.edit_tab(index) + else: + # Event is not interesting, raise to parent + QTabBar.mouseDoubleClickEvent(self, event) + + +class BaseTabs(QTabWidget): + """TabWidget with context menu and corner widgets""" + sig_close_tab = Signal(int) + + def __init__(self, parent, actions=None, menu=None, + corner_widgets=None, menu_use_tooltips=False): + QTabWidget.__init__(self, parent) + self.setUsesScrollButtons(True) + self.tabBar().setObjectName('pane-tabbar') + + self.corner_widgets = {} + self.menu_use_tooltips = menu_use_tooltips + + if menu is None: + self.menu = QMenu(self) + if actions: + add_actions(self.menu, actions) + else: + self.menu = menu + + self.setStyleSheet(str(PANES_TABBAR_STYLESHEET)) + + # Corner widgets + if corner_widgets is None: + corner_widgets = {} + corner_widgets.setdefault(Qt.TopLeftCorner, []) + corner_widgets.setdefault(Qt.TopRightCorner, []) + + self.browse_button = create_toolbutton( + self, icon=ima.icon('browse_tab'), tip=_("Browse tabs")) + self.browse_button.setStyleSheet(str(PANES_TABBAR_STYLESHEET)) + + self.browse_tabs_menu = QMenu(self) + self.browse_tabs_menu.setObjectName('checkbox-padding') + self.browse_button.setMenu(self.browse_tabs_menu) + self.browse_button.setPopupMode(self.browse_button.InstantPopup) + self.browse_tabs_menu.aboutToShow.connect(self.update_browse_tabs_menu) + corner_widgets[Qt.TopLeftCorner] += [self.browse_button] + + self.set_corner_widgets(corner_widgets) + + def update_browse_tabs_menu(self): + """Update browse tabs menu""" + self.browse_tabs_menu.clear() + names = [] + dirnames = [] + for index in range(self.count()): + if self.menu_use_tooltips: + text = to_text_string(self.tabToolTip(index)) + else: + text = to_text_string(self.tabText(index)) + names.append(text) + if osp.isfile(text): + # Testing if tab names are filenames + dirnames.append(osp.dirname(text)) + offset = None + + # If tab names are all filenames, removing common path: + if len(names) == len(dirnames): + common = get_common_path(dirnames) + if common is None: + offset = None + else: + offset = len(common)+1 + if offset <= 3: + # Common path is not a path but a drive letter... + offset = None + + for index, text in enumerate(names): + tab_action = create_action(self, text[offset:], + icon=self.tabIcon(index), + toggled=lambda state, index=index: + self.setCurrentIndex(index), + tip=self.tabToolTip(index)) + tab_action.setChecked(index == self.currentIndex()) + self.browse_tabs_menu.addAction(tab_action) + + def set_corner_widgets(self, corner_widgets): + """ + Set tabs corner widgets + corner_widgets: dictionary of (corner, widgets) + corner: Qt.TopLeftCorner or Qt.TopRightCorner + widgets: list of widgets (may contains integers to add spacings) + """ + assert isinstance(corner_widgets, dict) + assert all(key in (Qt.TopLeftCorner, Qt.TopRightCorner) + for key in corner_widgets) + self.corner_widgets.update(corner_widgets) + for corner, widgets in list(self.corner_widgets.items()): + cwidget = QWidget() + cwidget.hide() + + # This removes some white dots in our tabs (not all but most). + # See spyder-ide/spyder#15081 + cwidget.setObjectName('corner-widget') + cwidget.setStyleSheet( + "QWidget#corner-widget {border-radius: '0px'}") + + prev_widget = self.cornerWidget(corner) + if prev_widget: + prev_widget.close() + self.setCornerWidget(cwidget, corner) + clayout = QHBoxLayout() + clayout.setContentsMargins(0, 0, 0, 0) + for widget in widgets: + if isinstance(widget, int): + clayout.addSpacing(widget) + else: + clayout.addWidget(widget) + cwidget.setLayout(clayout) + cwidget.show() + + def add_corner_widgets(self, widgets, corner=Qt.TopRightCorner): + self.set_corner_widgets({corner: + self.corner_widgets.get(corner, [])+widgets}) + + def get_offset_pos(self, event): + """ + Add offset to position event to capture the mouse cursor + inside a tab. + """ + # This is necessary because event.pos() is the position in this + # widget, not in the tabBar. see spyder-ide/spyder#12617 + tb = self.tabBar() + point = tb.mapFromGlobal(event.globalPos()) + return tb.tabAt(point) + + def contextMenuEvent(self, event): + """Override Qt method""" + index = self.get_offset_pos(event) + self.setCurrentIndex(index) + if self.menu: + self.menu.popup(event.globalPos()) + + def mousePressEvent(self, event): + """Override Qt method""" + if event.button() == Qt.MidButton: + index = self.get_offset_pos(event) + if index >= 0: + self.sig_close_tab.emit(index) + event.accept() + return + QTabWidget.mousePressEvent(self, event) + + def keyPressEvent(self, event): + """Override Qt method""" + ctrl = event.modifiers() & Qt.ControlModifier + key = event.key() + handled = False + if ctrl and self.count() > 0: + index = self.currentIndex() + if key == Qt.Key_PageUp: + if index > 0: + self.setCurrentIndex(index - 1) + else: + self.setCurrentIndex(self.count() - 1) + handled = True + elif key == Qt.Key_PageDown: + if index < self.count() - 1: + self.setCurrentIndex(index + 1) + else: + self.setCurrentIndex(0) + handled = True + if not handled: + QTabWidget.keyPressEvent(self, event) + + def tab_navigate(self, delta=1): + """Ctrl+Tab""" + if delta > 0 and self.currentIndex() == self.count()-1: + index = delta-1 + elif delta < 0 and self.currentIndex() == 0: + index = self.count()+delta + else: + index = self.currentIndex()+delta + self.setCurrentIndex(index) + + def set_close_function(self, func): + """Setting Tabs close function + None -> tabs are not closable""" + state = func is not None + if state: + self.sig_close_tab.connect(func) + try: + # Assuming Qt >= 4.5 + QTabWidget.setTabsClosable(self, state) + self.tabCloseRequested.connect(func) + except AttributeError: + # Workaround for Qt < 4.5 + close_button = create_toolbutton(self, triggered=func, + icon=ima.icon('fileclose'), + tip=_("Close current tab")) + self.setCornerWidget(close_button if state else None) + + +class Tabs(BaseTabs): + """BaseTabs widget with movable tabs and tab navigation shortcuts.""" + # Signals + move_data = Signal(int, int) + move_tab_finished = Signal() + sig_move_tab = Signal(str, str, int, int) + + def __init__(self, parent, actions=None, menu=None, + corner_widgets=None, menu_use_tooltips=False, + rename_tabs=False, split_char='', + split_index=0): + BaseTabs.__init__(self, parent, actions, menu, + corner_widgets, menu_use_tooltips) + tab_bar = TabBar(self, parent, + rename_tabs=rename_tabs, + split_char=split_char, + split_index=split_index) + tab_bar.sig_move_tab.connect(self.move_tab) + tab_bar.sig_move_tab[(str, int, int)].connect( + self.move_tab_from_another_tabwidget) + self.setTabBar(tab_bar) + + CONF.config_shortcut( + lambda: self.tab_navigate(1), + context='editor', + name='go to next file', + parent=parent) + + CONF.config_shortcut( + lambda: self.tab_navigate(-1), + context='editor', + name='go to previous file', + parent=parent) + + CONF.config_shortcut( + lambda: self.sig_close_tab.emit(self.currentIndex()), + context='editor', + name='close file 1', + parent=parent) + + CONF.config_shortcut( + lambda: self.sig_close_tab.emit(self.currentIndex()), + context='editor', + name='close file 2', + parent=parent) + + @Slot(int, int) + def move_tab(self, index_from, index_to): + """Move tab inside a tabwidget""" + self.move_data.emit(index_from, index_to) + + tip, text = self.tabToolTip(index_from), self.tabText(index_from) + icon, widget = self.tabIcon(index_from), self.widget(index_from) + current_widget = self.currentWidget() + + self.removeTab(index_from) + self.insertTab(index_to, widget, icon, text) + self.setTabToolTip(index_to, tip) + + self.setCurrentWidget(current_widget) + self.move_tab_finished.emit() + + @Slot(str, int, int) + def move_tab_from_another_tabwidget(self, tabwidget_from, + index_from, index_to): + """Move tab from a tabwidget to another""" + + # We pass self object IDs as QString objs, because otherwise it would + # depend on the platform: long for 64bit, int for 32bit. Replacing + # by long all the time is not working on some 32bit platforms. + # See spyder-ide/spyder#1094 and spyder-ide/spyder#1098. + self.sig_move_tab.emit(tabwidget_from, to_text_string(id(self)), + index_from, index_to) From e7ab3365540164ef5f3d9ea243e67cc727e4a88b Mon Sep 17 00:00:00 2001 From: Quentin Peter Date: Wed, 27 Jul 2022 07:13:17 +0200 Subject: [PATCH 3/3] add .git-blame-ignore-revs --- .git-blame-ignore-revs | 2 ++ setup.cfg | 1 + 2 files changed, 3 insertions(+) create mode 100644 .git-blame-ignore-revs diff --git a/.git-blame-ignore-revs b/.git-blame-ignore-revs new file mode 100644 index 00000000000..09026fb0702 --- /dev/null +++ b/.git-blame-ignore-revs @@ -0,0 +1,2 @@ +# Removed CRLF from the entire codebase +bdfe0b59821951584de3f72768693a151ca8350a diff --git a/setup.cfg b/setup.cfg index 3f8f97243dd..026bb4ef2be 100644 --- a/setup.cfg +++ b/setup.cfg @@ -3,6 +3,7 @@ ignore = .codecov.yml .codecov.yml .coveragerc + .git-blame-ignore-revs .pep8speaks.yml Announcements.md CHANGELOG.md