{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: {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: %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 "
- f' '
- f"{self.lineno} ({self.colno}): "
- f"{match} "
+ f' '
+ f"{self.lineno} ({self.colno}): "
+ f"{match} {0} {1} {1} {0} {1} {1}
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=
'.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:
'.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:
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 += '
'
-
- 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{}
'.format(error)
- else:
- msg += '{}
"
- "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 += '
'
+
+ 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{}
'.format(error)
+ else:
+ msg += '{}
"
+ "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"
- 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 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_died_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 db410bfbf41..672e148ec9a 100644
--- a/spyder/plugins/layout/layouts.py
+++ b/spyder/plugins/layout/layouts.py
@@ -1,267 +1,267 @@
-# -*- 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.FramesExplorer, 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.FramesExplorer, 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.FramesExplorer, 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.FramesExplorer, 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.FramesExplorer, 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.FramesExplorer, 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.FramesExplorer, 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.FramesExplorer, 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.FramesExplorer, 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.FramesExplorer, 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 5770c714601..756c3dca99d 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',
- 'frames_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',
+ 'frames_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"{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"{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(
- '
"
+ "{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(
+ '
"
- "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 c3464df0c52..ca94dde209e 100644
--- a/spyder/plugins/shortcuts/widgets/table.py
+++ b/spyder/plugins/shortcuts/widgets/table.py
@@ -1,954 +1,954 @@
-# -*- 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, QRegExp)
-from qtpy.QtGui import QIcon, QKeySequence, QRegExpValidator
-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,
- 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(QLineEdit):
- """Textbox for filtering listed shortcuts in the table."""
-
- def __init__(self, parent, callback=None, main=None,
- regex_base=VALID_FINDER_CHARS):
- super().__init__(parent)
- self._parent = parent
- self.main = main
-
- # Widget setup
- regex = QRegExp(regex_base + "{100}")
- self.setValidator(QRegExpValidator(regex))
-
- # Signals
- if callback:
- self.textChanged.connect(callback)
-
- 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 _('
'.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 = '
'.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 = '
"
- "
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 53ad68b73d9..44e315ee173 100644
--- a/spyder/plugins/variableexplorer/plugin.py
+++ b/spyder/plugins/variableexplorer/plugin.py
@@ -1,75 +1,75 @@
-# -*- 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 on_connection_to_external_spyder_kernel(self, shellwidget):
- """Send namespace view settings to the kernel."""
- widget = self.get_widget_for_shellwidget(shellwidget)
- if widget is None:
- return
- widget.set_namespace_view_settings()
- widget.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 on_connection_to_external_spyder_kernel(self, shellwidget):
+ """Send namespace view settings to the kernel."""
+ widget = self.get_widget_for_shellwidget(shellwidget)
+ if widget is None:
+ return
+ widget.set_namespace_view_settings()
+ widget.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
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 c70326c59dd..23397cdadc2 100644
--- a/spyder/plugins/variableexplorer/widgets/main_widget.py
+++ b/spyder/plugins/variableexplorer/widgets/main_widget.py
@@ -1,593 +1,593 @@
-# -*- 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
-
-# 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)
-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
-
- # ---- 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.toggle_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):
- """Update the actions."""
- action = self.get_action(VariableExplorerWidgetActions.ToggleMinMax)
- action.setEnabled(is_module_installed('numpy'))
- nsb = self.current_widget()
- if nsb:
- save_data_action = self.get_action(
- VariableExplorerWidgetActions.SaveData)
- save_data_action.setEnabled(nsb.filename is not None)
- search_action = self.get_action(VariableExplorerWidgetActions.Search)
- if nsb is None:
- checked = False
- else:
- checked = nsb.finder_is_visible()
- search_action.setChecked(checked)
-
- @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 switch_widget(self, nsb, old_nsb):
- """Set the current NamespaceBrowser."""
- pass
-
- # ---- Public API
- # ------------------------------------------------------------------------
-
- def create_new_widget(self, shellwidget):
- """Create new NamespaceBrowser."""
- nsb = NamespaceBrowser(self)
- nsb.sig_hide_finder_requested.connect(
- self.hide_finder)
- 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.set_shellwidget(shellwidget)
- nsb.setup()
- self._set_actions_and_menus(nsb)
-
- # To update the Variable Explorer after execution
- shellwidget.executed.connect(
- nsb.refresh_namespacebrowser)
- shellwidget.sig_kernel_started.connect(
- nsb.on_kernel_started)
- shellwidget.sig_kernel_reset.connect(
- nsb.on_kernel_started)
-
- return nsb
-
- def close_widget(self, nsb):
- """Close NamespaceBrowser."""
- nsb.sig_hide_finder_requested.disconnect(
- self.hide_finder)
- nsb.sig_free_memory_requested.disconnect(
- self.free_memory)
- nsb.sig_start_spinner_requested.disconnect(
- self.start_spinner)
- nsb.sig_stop_spinner_requested.disconnect(
- self.stop_spinner)
- nsb.shellwidget.executed.disconnect(
- nsb.refresh_namespacebrowser)
- nsb.shellwidget.sig_kernel_started.disconnect(
- nsb.on_kernel_started)
- nsb.shellwidget.sig_kernel_reset.disconnect(
- nsb.on_kernel_started)
- 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 toggle_finder(self, checked):
- """Hide or show the finder."""
- widget = self.current_widget()
- if widget is None:
- return
- widget.toggle_finder(checked)
-
- @Slot()
- def hide_finder(self):
- """Hide the finder."""
- action = self.get_action(VariableExplorerWidgetActions.Search)
- action.setChecked(False)
-
- 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
+
+# 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)
+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
+
+ # ---- 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.toggle_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):
+ """Update the actions."""
+ action = self.get_action(VariableExplorerWidgetActions.ToggleMinMax)
+ action.setEnabled(is_module_installed('numpy'))
+ nsb = self.current_widget()
+ if nsb:
+ save_data_action = self.get_action(
+ VariableExplorerWidgetActions.SaveData)
+ save_data_action.setEnabled(nsb.filename is not None)
+ search_action = self.get_action(VariableExplorerWidgetActions.Search)
+ if nsb is None:
+ checked = False
+ else:
+ checked = nsb.finder_is_visible()
+ search_action.setChecked(checked)
+
+ @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 switch_widget(self, nsb, old_nsb):
+ """Set the current NamespaceBrowser."""
+ pass
+
+ # ---- Public API
+ # ------------------------------------------------------------------------
+
+ def create_new_widget(self, shellwidget):
+ """Create new NamespaceBrowser."""
+ nsb = NamespaceBrowser(self)
+ nsb.sig_hide_finder_requested.connect(
+ self.hide_finder)
+ 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.set_shellwidget(shellwidget)
+ nsb.setup()
+ self._set_actions_and_menus(nsb)
+
+ # To update the Variable Explorer after execution
+ shellwidget.executed.connect(
+ nsb.refresh_namespacebrowser)
+ shellwidget.sig_kernel_started.connect(
+ nsb.on_kernel_started)
+ shellwidget.sig_kernel_reset.connect(
+ nsb.on_kernel_started)
+
+ return nsb
+
+ def close_widget(self, nsb):
+ """Close NamespaceBrowser."""
+ nsb.sig_hide_finder_requested.disconnect(
+ self.hide_finder)
+ nsb.sig_free_memory_requested.disconnect(
+ self.free_memory)
+ nsb.sig_start_spinner_requested.disconnect(
+ self.start_spinner)
+ nsb.sig_stop_spinner_requested.disconnect(
+ self.stop_spinner)
+ nsb.shellwidget.executed.disconnect(
+ nsb.refresh_namespacebrowser)
+ nsb.shellwidget.sig_kernel_started.disconnect(
+ nsb.on_kernel_started)
+ nsb.shellwidget.sig_kernel_reset.disconnect(
+ nsb.on_kernel_started)
+ 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 toggle_finder(self, checked):
+ """Hide or show the finder."""
+ widget = self.current_widget()
+ if widget is None:
+ return
+ widget.toggle_finder(checked)
+
+ @Slot()
+ def hide_finder(self):
+ """Hide the finder."""
+ action = self.get_action(VariableExplorerWidgetActions.Search)
+ action.setChecked(False)
+
+ 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 19a3a1d7639..19437185668 100644
--- a/spyder/plugins/variableexplorer/widgets/namespacebrowser.py
+++ b/spyder/plugins/variableexplorer/widgets/namespacebrowser.py
@@ -1,362 +1,362 @@
-# -*- 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
-from pickle import UnpicklingError
-
-# 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, QInputDialog,
- QMessageBox, QVBoxLayout, QWidget)
-from spyder_kernels.comms.commbase import CommError
-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 FinderWidget
-
-
-# Localization
-_ = get_translation('spyder')
-
-# Constants
-VALID_VARIABLE_CHARS = r"[^\w+*=¡!¿?'\"#$%&()/<>\-\[\]{}^`´;,|¬]*\w"
-
-# Max time before giving up when making a blocking call to the kernel
-CALL_KERNEL_TIMEOUT = 30
-
-
-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
-
- # Widgets
- self.editor = None
- self.shellwidget = None
- self.finder = None
-
- def toggle_finder(self, show):
- """Show and hide the finder."""
- self.finder.set_visible(show)
- if not show:
- self.editor.setFocus()
-
- def do_find(self, text):
- """Search for text."""
- if self.editor is not None:
- self.editor.do_find(text)
-
- def finder_is_visible(self):
- """Check if the finder is visible."""
- if self.finder is None:
- return False
- return self.finder.isVisible()
-
- def setup(self):
- """
- Setup the namespace browser with provided options.
- """
- assert self.shellwidget is not None
-
- if self.editor is not None:
- self.set_namespace_view_settings()
- self.refresh_table()
- else:
- # Widgets
- self.editor = RemoteCollectionsEditorTableView(
- self,
- data=None,
- shellwidget=self.shellwidget,
- create_menu=False,
- )
- key_filter_dict = {
- Qt.Key_Up: self.editor.previous_row,
- Qt.Key_Down: self.editor.next_row
- }
- self.finder = FinderWidget(
- self,
- find_on_change=True,
- regex_base=VALID_VARIABLE_CHARS,
- key_filter_dict=key_filter_dict
- )
-
- # 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)
-
- self.finder.sig_find_text.connect(self.do_find)
- self.finder.sig_hide_finder_requested.connect(
- self.sig_hide_finder_requested)
-
- # Layout
- layout = QVBoxLayout()
- layout.setContentsMargins(0, 0, 0, 0)
- layout.addWidget(self.editor)
- layout.addSpacing(1)
- layout.addWidget(self.finder)
- 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
-
- def refresh_table(self):
- """Refresh variable table."""
- self.refresh_namespacebrowser()
- try:
- self.editor.resizeRowToContents()
- except TypeError:
- pass
-
- def refresh_namespacebrowser(self, interrupt=True):
- """Refresh namespace browser"""
- self.shellwidget.call_kernel(
- interrupt=interrupt,
- callback=self.process_remote_view
- ).get_namespace_view()
-
- self.shellwidget.call_kernel(
- interrupt=interrupt,
- callback=self.set_var_properties
- ).get_var_properties()
-
- def set_namespace_view_settings(self):
- """Set the namespace view settings"""
- settings = self.get_view_settings()
- self.shellwidget.call_kernel(
- interrupt=True
- ).set_namespace_view_settings(settings)
-
- def on_kernel_started(self):
- self.set_namespace_view_settings()
- self.refresh_namespacebrowser(interrupt=False)
-
- def process_remote_view(self, remote_view):
- """Process remote view"""
- 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.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 load_data(self, filename, ext):
- """Load data from a file."""
- overwrite = False
- if self.editor.var_properties:
- message = _('Do you want to overwrite old '
- 'variables (if any) in the namespace '
- 'when loading the data?')
- buttons = QMessageBox.Yes | QMessageBox.No
- result = QMessageBox.question(
- self, _('Data loading'), message, buttons)
- overwrite = result == QMessageBox.Yes
- try:
- return self.shellwidget.call_kernel(
- blocking=True,
- display_error=True,
- timeout=CALL_KERNEL_TIMEOUT).load_data(
- filename, ext, overwrite=overwrite)
- except ImportError as msg:
- module = str(msg).split("'")[1]
- msg = _("Spyder is unable to open the file "
- "you're trying to load because {module} is "
- "not installed. Please install "
- "this package in your working environment."
- "
").format(module=module)
- return msg
- except TimeoutError:
- msg = _("Data is too big to be loaded")
- return msg
- except (UnpicklingError, RuntimeError, CommError):
- return None
-
- 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.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)
-
- def save_namespace(self, filename):
- try:
- return self.shellwidget.call_kernel(
- blocking=True,
- display_error=True,
- timeout=CALL_KERNEL_TIMEOUT).save_namespace(filename)
- except TimeoutError:
- msg = _("Data is too big to be saved")
- return msg
- except (UnpicklingError, RuntimeError, CommError):
- return None
+# -*- 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
+from pickle import UnpicklingError
+
+# 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, QInputDialog,
+ QMessageBox, QVBoxLayout, QWidget)
+from spyder_kernels.comms.commbase import CommError
+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 FinderWidget
+
+
+# Localization
+_ = get_translation('spyder')
+
+# Constants
+VALID_VARIABLE_CHARS = r"[^\w+*=¡!¿?'\"#$%&()/<>\-\[\]{}^`´;,|¬]*\w"
+
+# Max time before giving up when making a blocking call to the kernel
+CALL_KERNEL_TIMEOUT = 30
+
+
+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
+
+ # Widgets
+ self.editor = None
+ self.shellwidget = None
+ self.finder = None
+
+ def toggle_finder(self, show):
+ """Show and hide the finder."""
+ self.finder.set_visible(show)
+ if not show:
+ self.editor.setFocus()
+
+ def do_find(self, text):
+ """Search for text."""
+ if self.editor is not None:
+ self.editor.do_find(text)
+
+ def finder_is_visible(self):
+ """Check if the finder is visible."""
+ if self.finder is None:
+ return False
+ return self.finder.isVisible()
+
+ def setup(self):
+ """
+ Setup the namespace browser with provided options.
+ """
+ assert self.shellwidget is not None
+
+ if self.editor is not None:
+ self.set_namespace_view_settings()
+ self.refresh_table()
+ else:
+ # Widgets
+ self.editor = RemoteCollectionsEditorTableView(
+ self,
+ data=None,
+ shellwidget=self.shellwidget,
+ create_menu=False,
+ )
+ key_filter_dict = {
+ Qt.Key_Up: self.editor.previous_row,
+ Qt.Key_Down: self.editor.next_row
+ }
+ self.finder = FinderWidget(
+ self,
+ find_on_change=True,
+ regex_base=VALID_VARIABLE_CHARS,
+ key_filter_dict=key_filter_dict
+ )
+
+ # 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)
+
+ self.finder.sig_find_text.connect(self.do_find)
+ self.finder.sig_hide_finder_requested.connect(
+ self.sig_hide_finder_requested)
+
+ # Layout
+ layout = QVBoxLayout()
+ layout.setContentsMargins(0, 0, 0, 0)
+ layout.addWidget(self.editor)
+ layout.addSpacing(1)
+ layout.addWidget(self.finder)
+ 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
+
+ def refresh_table(self):
+ """Refresh variable table."""
+ self.refresh_namespacebrowser()
+ try:
+ self.editor.resizeRowToContents()
+ except TypeError:
+ pass
+
+ def refresh_namespacebrowser(self, interrupt=True):
+ """Refresh namespace browser"""
+ self.shellwidget.call_kernel(
+ interrupt=interrupt,
+ callback=self.process_remote_view
+ ).get_namespace_view()
+
+ self.shellwidget.call_kernel(
+ interrupt=interrupt,
+ callback=self.set_var_properties
+ ).get_var_properties()
+
+ def set_namespace_view_settings(self):
+ """Set the namespace view settings"""
+ settings = self.get_view_settings()
+ self.shellwidget.call_kernel(
+ interrupt=True
+ ).set_namespace_view_settings(settings)
+
+ def on_kernel_started(self):
+ self.set_namespace_view_settings()
+ self.refresh_namespacebrowser(interrupt=False)
+
+ def process_remote_view(self, remote_view):
+ """Process remote view"""
+ 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.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 load_data(self, filename, ext):
+ """Load data from a file."""
+ overwrite = False
+ if self.editor.var_properties:
+ message = _('Do you want to overwrite old '
+ 'variables (if any) in the namespace '
+ 'when loading the data?')
+ buttons = QMessageBox.Yes | QMessageBox.No
+ result = QMessageBox.question(
+ self, _('Data loading'), message, buttons)
+ overwrite = result == QMessageBox.Yes
+ try:
+ return self.shellwidget.call_kernel(
+ blocking=True,
+ display_error=True,
+ timeout=CALL_KERNEL_TIMEOUT).load_data(
+ filename, ext, overwrite=overwrite)
+ except ImportError as msg:
+ module = str(msg).split("'")[1]
+ msg = _("Spyder is unable to open the file "
+ "you're trying to load because {module} is "
+ "not installed. Please install "
+ "this package in your working environment."
+ "
").format(module=module)
+ return msg
+ except TimeoutError:
+ msg = _("Data is too big to be loaded")
+ return msg
+ except (UnpicklingError, RuntimeError, CommError):
+ return None
+
+ 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.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)
+
+ def save_namespace(self, filename):
+ try:
+ return self.shellwidget.call_kernel(
+ blocking=True,
+ display_error=True,
+ timeout=CALL_KERNEL_TIMEOUT).save_namespace(filename)
+ except TimeoutError:
+ msg = _("Data is too big to be saved")
+ return msg
+ except (UnpicklingError, RuntimeError, CommError):
+ return None
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 != '
"
- "{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
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
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
- 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 68ad22c3734..ab0f6524167 100644
--- a/spyder/widgets/collectionseditor.py
+++ b/spyder/widgets/collectionseditor.py
@@ -1,1913 +1,1913 @@
-# -*- 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):
- # fetch more data
- reminder = self.total_rows - self.rows_loaded
- if reminder <= 0:
- # Everything is loaded
- return
- 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.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.namespacebrowser = parent
- self.shellwidget = shellwidget
- self.var_properties = {}
- self.dictfilter = None
- self.delegate = None
- self.readonly = False
-
- 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.namespacebrowser.refresh_namespacebrowser()
-
- def remove_values(self, names):
- """Remove values from data"""
- for name in names:
- self.shellwidget.remove_value(name)
- self.namespacebrowser.refresh_namespacebrowser()
-
- def copy_value(self, orig_name, new_name):
- """Copy value"""
- self.shellwidget.copy_value(orig_name, new_name)
- self.namespacebrowser.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 do_find(self, text):
- """Update the regex text for the variable finder."""
- text = text.replace(' ', '').lower()
-
- # Make sure everything is loaded
- self.source_model.load_all()
-
- 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
-
- 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):
+ # fetch more data
+ reminder = self.total_rows - self.rows_loaded
+ if reminder <= 0:
+ # Everything is loaded
+ return
+ 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.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.namespacebrowser = parent
+ self.shellwidget = shellwidget
+ self.var_properties = {}
+ self.dictfilter = None
+ self.delegate = None
+ self.readonly = False
+
+ 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.namespacebrowser.refresh_namespacebrowser()
+
+ def remove_values(self, names):
+ """Remove values from data"""
+ for name in names:
+ self.shellwidget.remove_value(name)
+ self.namespacebrowser.refresh_namespacebrowser()
+
+ def copy_value(self, orig_name, new_name):
+ """Copy value"""
+ self.shellwidget.copy_value(orig_name, new_name)
+ self.namespacebrowser.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 do_find(self, text):
+ """Update the regex text for the variable finder."""
+ text = text.replace(' ', '').lower()
+
+ # Make sure everything is loaded
+ self.source_model.load_all()
+
+ 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
+
+ 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'''
-
'
-
- 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() == '
')
- 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 += (
- '
'
- '
'
- '
'.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'''
+
'
+
+ 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() == '
')
+ 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 += (
+ '
'
+ '
'
+ '
'.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)