File: C:/Users/fred/anaconda3/Lib/site-packages/spyder/plugins/plots/widgets/figurebrowser.py
# -*- coding: utf-8 -*-
#
# Copyright © Spyder Project Contributors
# Licensed under the terms of the MIT License
# (see spyder/__init__.py for details)
"""
Figure browser widget
This is the main widget used in the Plots plugin
"""
# Standard library imports
import datetime
import os.path as osp
import sys
# Third library imports
from qtconsole.svg import svg_to_clipboard, svg_to_image
from qtpy import PYQT5
from qtpy.compat import getexistingdirectory, getsavefilename
from qtpy.QtCore import QEvent, QPoint, QRect, QSize, Qt, QTimer, Signal, Slot
from qtpy.QtGui import QPainter, QPixmap
from qtpy.QtWidgets import (QApplication, QFrame, QGridLayout, QHBoxLayout,
QScrollArea, QScrollBar, QSplitter, QStyle,
QVBoxLayout, QWidget)
# Local library imports
from spyder.api.translations import _
from spyder.api.widgets.mixins import SpyderWidgetMixin
from spyder.utils.misc import getcwd_or_home
from spyder.utils.palette import QStylePalette
# TODO:
# - [ ] Generalize style updates, handle dark_interface with widget option
def save_figure_tofile(fig, fmt, fname):
"""Save fig to fname in the format specified by fmt."""
root, ext = osp.splitext(fname)
if ext == '.png' and fmt == 'image/svg+xml':
qimg = svg_to_image(fig)
qimg.save(fname)
else:
if fmt == 'image/svg+xml' and isinstance(fig, str):
fig = fig.encode('utf-8')
with open(fname, 'wb') as f:
f.write(fig)
def get_unique_figname(dirname, root, ext, start_at_zero=False):
"""
Append a number to "root" to form a filename that does not already exist
in "dirname".
"""
i = 1
figname = '{}{}'.format(root, ext)
if start_at_zero:
i = 0
figname = '{} ({}){}'.format(root, i, ext)
while True:
if osp.exists(osp.join(dirname, figname)):
figname = '{} ({}){}'.format(root, i, ext)
i += 1
else:
return osp.join(dirname, figname)
class FigureBrowser(QWidget, SpyderWidgetMixin):
"""
Widget to browse the figures that were sent by the kernel to the IPython
console to be plotted inline.
"""
sig_figure_loaded = Signal()
"""This signal is emitted when a new figure is loaded."""
sig_figure_menu_requested = Signal(QPoint)
"""
This signal is emitted to request a context menu on the main figure
canvas.
Parameters
----------
point: QPoint
The QPoint in global coordinates where the menu was requested.
"""
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_save_dir_changed = Signal(str)
"""
This signal is emitted to inform that the current folder where images are
saved has changed.
Parameters
----------
save_dir: str
The new path where images are saved.
"""
sig_thumbnail_menu_requested = Signal(QPoint, object)
"""
This signal is emitted to request a context menu on the figure thumbnails.
Parameters
----------
point: QPoint
The QPoint in global coordinates where the menu was requested.
figure_thumbnail: spyder.plugins.plots.widget.figurebrowser.FigureThumbnail
The clicked figure thumbnail.
"""
sig_zoom_changed = Signal(int)
"""
This signal is emitted when zoom has changed.
Parameters
----------
zoom_value: int
The new value for the zoom property.
"""
def __init__(self, parent=None, background_color=None):
if PYQT5:
super().__init__(parent=parent, class_parent=parent)
else:
QWidget.__init__(self, parent)
SpyderWidgetMixin.__init__(self, class_parent=parent)
self.shellwidget = None
self.is_visible = True
self.figviewer = None
self.setup_in_progress = False
self.background_color = background_color
self.mute_inline_plotting = None
self.zoom_disp_value = None
# Setup the figure viewer.
self.figviewer = FigureViewer(parent=self,
background_color=self.background_color)
self.figviewer.sig_context_menu_requested.connect(
self.sig_figure_menu_requested)
self.figviewer.sig_figure_loaded.connect(self.sig_figure_loaded)
self.figviewer.sig_zoom_changed.connect(self.sig_zoom_changed)
self.figviewer.sig_zoom_changed.connect(self._update_zoom_value)
# Setup the thumbnail scrollbar.
self.thumbnails_sb = ThumbnailScrollBar(
self.figviewer,
parent=self,
background_color=self.background_color,
)
self.thumbnails_sb.sig_context_menu_requested.connect(
self.sig_thumbnail_menu_requested)
self.thumbnails_sb.sig_save_dir_changed.connect(
self.sig_save_dir_changed)
self.thumbnails_sb.sig_redirect_stdio_requested.connect(
self.sig_redirect_stdio_requested)
# Create the layout.
self.splitter = splitter = QSplitter(parent=self)
splitter.addWidget(self.figviewer)
splitter.addWidget(self.thumbnails_sb)
splitter.setFrameStyle(QScrollArea().frameStyle())
splitter.setContentsMargins(0, 0, 0, 0)
layout = QHBoxLayout(self)
layout.addWidget(splitter)
self.setLayout(layout)
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(0)
self.setContentsMargins(0, 0, 0, 0)
def _update_zoom_value(self, value):
"""
Used in testing.
"""
self.zoom_disp_value = value
def setup(self, options):
"""Setup the figure browser with provided options."""
self.splitter.setContentsMargins(0, 0, 0, 0)
for option, value in options.items():
if option == 'auto_fit_plotting':
self.change_auto_fit_plotting(value)
elif option == 'mute_inline_plotting':
self.mute_inline_plotting = value
elif option == 'show_plot_outline':
self.show_fig_outline_in_viewer(value)
elif option == 'save_dir':
self.thumbnails_sb.save_dir = value
def update_splitter_widths(self, base_width):
"""
Update the widths to provide the scrollbar with a fixed minimum width.
Parameters
----------
base_width: int
The available splitter width.
"""
min_sb_width = self.thumbnails_sb._min_scrollbar_width
if base_width - min_sb_width > 0:
self.splitter.setSizes([base_width - min_sb_width, min_sb_width])
def show_fig_outline_in_viewer(self, state):
"""Draw a frame around the figure viewer if state is True."""
if state is True:
self.figviewer.figcanvas.setStyleSheet(
"FigureCanvas{border: 2px solid %s;}" %
QStylePalette.COLOR_BACKGROUND_4
)
else:
self.figviewer.figcanvas.setStyleSheet(
"FigureCanvas{border: 0px;}")
def change_auto_fit_plotting(self, state):
"""Change the auto_fit_plotting option and scale images."""
self.figviewer.auto_fit_plotting = state
def set_shellwidget(self, shellwidget):
"""Bind the shellwidget instance to the figure browser"""
self.shellwidget = shellwidget
shellwidget.set_figurebrowser(self)
shellwidget.sig_new_inline_figure.connect(self._handle_new_figure)
def _handle_new_figure(self, fig, fmt):
"""
Handle when a new figure is sent to the IPython console by the
kernel.
"""
self.thumbnails_sb.add_thumbnail(fig, fmt)
# ---- Toolbar Handlers
def zoom_in(self):
"""Zoom the figure in by a single step in the figure viewer."""
self.figviewer.zoom_in()
def zoom_out(self):
"""Zoom the figure out by a single step in the figure viewer."""
self.figviewer.zoom_out()
def go_previous_thumbnail(self):
"""
Select the thumbnail previous to the currently selected one in the
thumbnail scrollbar.
"""
self.thumbnails_sb.go_previous_thumbnail()
def go_next_thumbnail(self):
"""
Select the thumbnail next to the currently selected one in the
thumbnail scrollbar.
"""
self.thumbnails_sb.go_next_thumbnail()
def save_figure(self):
"""Save the currently selected figure in the thumbnail scrollbar."""
self.thumbnails_sb.save_current_figure_as()
def save_all_figures(self):
"""Save all the figures in a selected directory."""
return self.thumbnails_sb.save_all_figures_as()
def close_figure(self):
"""Close the currently selected figure in the thumbnail scrollbar."""
self.thumbnails_sb.remove_current_thumbnail()
def close_all_figures(self):
"""Close all the figures in the thumbnail scrollbar."""
self.thumbnails_sb.remove_all_thumbnails()
def copy_figure(self):
"""Copy figure from figviewer to clipboard."""
if self.figviewer and self.figviewer.figcanvas.fig:
self.figviewer.figcanvas.copy_figure()
class FigureViewer(QScrollArea, SpyderWidgetMixin):
"""
A scrollarea that displays a single FigureCanvas with zooming and panning
capability with CTRL + Mouse_wheel and Left-press mouse button event.
"""
sig_zoom_changed = Signal(int)
"""
This signal is emitted when zoom has changed.
Parameters
----------
zoom_value: int
The new value for the zoom property.
"""
sig_context_menu_requested = Signal(QPoint)
"""
This signal is emitted to request a context menu.
Parameters
----------
point: QPoint
The QPoint in global coordinates where the menu was requested.
"""
sig_figure_loaded = Signal()
"""This signal is emitted when a new figure is loaded."""
def __init__(self, parent=None, background_color=None):
if PYQT5:
super().__init__(parent, class_parent=parent)
else:
QScrollArea.__init__(self, parent)
SpyderWidgetMixin.__init__(self, class_parent=parent)
self.setAlignment(Qt.AlignCenter)
self.viewport().setObjectName("figviewport")
self.viewport().setStyleSheet(
"#figviewport {background-color:" + str(background_color) + "}")
self.setFrameStyle(0)
self.background_color = background_color
self._scalefactor = 0
self._scalestep = 1.2
self._sfmax = 10
self._sfmin = -10
self.setup_figcanvas()
self.auto_fit_plotting = False
# An internal flag that tracks when the figure is being panned.
self._ispanning = False
@property
def auto_fit_plotting(self):
"""
Return whether to automatically fit the plot to the scroll area size.
"""
return self._auto_fit_plotting
@auto_fit_plotting.setter
def auto_fit_plotting(self, value):
"""
Set whether to automatically fit the plot to the scroll area size.
"""
self._auto_fit_plotting = value
if value:
self.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
self.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
else:
self.setVerticalScrollBarPolicy(Qt.ScrollBarAsNeeded)
self.setHorizontalScrollBarPolicy(Qt.ScrollBarAsNeeded)
self.scale_image()
def setup_figcanvas(self):
"""Setup the FigureCanvas."""
self.figcanvas = FigureCanvas(parent=self,
background_color=self.background_color)
self.figcanvas.installEventFilter(self)
self.figcanvas.customContextMenuRequested.connect(
self.show_context_menu)
self.setWidget(self.figcanvas)
def show_context_menu(self, qpoint):
"""Only emit context menu signal if there is a figure."""
if self.figcanvas and self.figcanvas.fig is not None:
# Convert to global
point = self.figcanvas.mapToGlobal(qpoint)
self.sig_context_menu_requested.emit(point)
def load_figure(self, fig, fmt):
"""Set a new figure in the figure canvas."""
self.figcanvas.load_figure(fig, fmt)
self.sig_figure_loaded.emit()
self.scale_image()
self.figcanvas.repaint()
def eventFilter(self, widget, event):
"""A filter to control the zooming and panning of the figure canvas."""
# ---- Zooming
if event.type() == QEvent.Wheel and not self.auto_fit_plotting:
modifiers = QApplication.keyboardModifiers()
if modifiers == Qt.ControlModifier:
if event.angleDelta().y() > 0:
self.zoom_in()
else:
self.zoom_out()
return True
else:
return False
# ---- Scaling
elif event.type() == QEvent.Paint and self.auto_fit_plotting:
self.scale_image()
# ---- Panning
# Set ClosedHandCursor:
elif event.type() == QEvent.MouseButtonPress:
if event.button() == Qt.LeftButton:
QApplication.setOverrideCursor(Qt.ClosedHandCursor)
self._ispanning = True
self.xclick = event.globalX()
self.yclick = event.globalY()
# Reset Cursor:
elif event.type() == QEvent.MouseButtonRelease:
QApplication.restoreOverrideCursor()
self._ispanning = False
# Move ScrollBar:
elif event.type() == QEvent.MouseMove:
if self._ispanning:
dx = self.xclick - event.globalX()
self.xclick = event.globalX()
dy = self.yclick - event.globalY()
self.yclick = event.globalY()
scrollBarH = self.horizontalScrollBar()
scrollBarH.setValue(scrollBarH.value() + dx)
scrollBarV = self.verticalScrollBar()
scrollBarV.setValue(scrollBarV.value() + dy)
return QWidget.eventFilter(self, widget, event)
# ---- Figure Scaling Handlers
def zoom_in(self):
"""Scale the image up by one scale step."""
if self._scalefactor <= self._sfmax:
self._scalefactor += 1
self.scale_image()
self._adjust_scrollbar(self._scalestep)
def zoom_out(self):
"""Scale the image down by one scale step."""
if self._scalefactor >= self._sfmin:
self._scalefactor -= 1
self.scale_image()
self._adjust_scrollbar(1/self._scalestep)
def scale_image(self):
"""Scale the image size."""
fwidth = self.figcanvas.fwidth
fheight = self.figcanvas.fheight
# Don't auto fit plotting
if not self.auto_fit_plotting:
new_width = int(fwidth * self._scalestep ** self._scalefactor)
new_height = int(fheight * self._scalestep ** self._scalefactor)
# Auto fit plotting
# Scale the image to fit the figviewer size while respecting the ratio.
else:
size = self.size()
style = self.style()
width = (size.width() -
style.pixelMetric(QStyle.PM_LayoutLeftMargin) -
style.pixelMetric(QStyle.PM_LayoutRightMargin))
height = (size.height() -
style.pixelMetric(QStyle.PM_LayoutTopMargin) -
style.pixelMetric(QStyle.PM_LayoutBottomMargin))
self.figcanvas.setToolTip('')
try:
if (fwidth / fheight) > (width / height):
new_width = int(width)
new_height = int(width / fwidth * fheight)
else:
new_height = int(height)
new_width = int(height / fheight * fwidth)
except ZeroDivisionError:
icon = self.create_icon('broken_image')
self.figcanvas._qpix_orig = icon.pixmap(fwidth, fheight)
self.figcanvas.setToolTip(
_('The image is broken, please try to generate it again'))
new_width = fwidth
new_height = fheight
if self.figcanvas.size() != QSize(new_width, new_height):
self.figcanvas.setFixedSize(new_width, new_height)
self.sig_zoom_changed.emit(self.get_scaling())
def get_scaling(self):
"""Get the current scaling of the figure in percent."""
width = self.figcanvas.width()
fwidth = self.figcanvas.fwidth
if fwidth != 0:
return round(width / fwidth * 100)
else:
return 100
def reset_original_image(self):
"""Reset the image to its original size."""
self._scalefactor = 0
self.scale_image()
def _adjust_scrollbar(self, f):
"""
Adjust the scrollbar position to take into account the zooming of
the figure.
"""
# Adjust horizontal scrollbar :
hb = self.horizontalScrollBar()
hb.setValue(int(f * hb.value() + ((f - 1) * hb.pageStep()/2)))
# Adjust the vertical scrollbar :
vb = self.verticalScrollBar()
vb.setValue(int(f * vb.value() + ((f - 1) * vb.pageStep()/2)))
class ThumbnailScrollBar(QFrame):
"""
A widget that manages the display of the FigureThumbnails that are
created when a figure is sent to the IPython console by the kernel and
that controls what is displayed in the FigureViewer.
"""
_min_scrollbar_width = 100
# Signals
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_save_dir_changed = Signal(str)
"""
This signal is emitted to inform that the current folder where images are
saved has changed.
Parameters
----------
save_dir: str
The new path where images are saved.
"""
sig_context_menu_requested = Signal(QPoint, object)
"""
This signal is emitted to request a context menu.
Parameters
----------
point: QPoint
The QPoint in global coordinates where the menu was requested.
"""
def __init__(self, figure_viewer, parent=None, background_color=None):
super().__init__(parent)
self._thumbnails = []
self.background_color = background_color
self.save_dir = getcwd_or_home()
self.current_thumbnail = None
self.set_figureviewer(figure_viewer)
self.setup_gui()
# Because the range of Qt scrollareas is not updated immediately
# after a new item is added to it, setting the scrollbar's value
# to its maximum value after adding a new item will scroll down to
# the penultimate item instead of the last.
# So to scroll programmatically to the latest item after it
# is added to the scrollarea, we need to do it instead in a slot
# connected to the scrollbar's rangeChanged signal.
# See spyder-ide/spyder#10914 for more details.
self._new_thumbnail_added = False
self.scrollarea.verticalScrollBar().rangeChanged.connect(
self._scroll_to_newest_item)
def setup_gui(self):
"""Setup the main layout of the widget."""
layout = QVBoxLayout(self)
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(0)
layout.addWidget(self.setup_scrollarea())
def setup_scrollarea(self):
"""Setup the scrollarea that will contain the FigureThumbnails."""
self.view = QWidget()
self.scene = QGridLayout(self.view)
self.scene.setContentsMargins(0, 0, 0, 0)
# The vertical spacing between the thumbnails.
# Note that we need to set this value explicitly or else the tests
# are failing on macOS. See spyder-ide/spyder#11576.
self.scene.setSpacing(5)
self.scrollarea = QScrollArea()
self.scrollarea.setWidget(self.view)
self.scrollarea.setWidgetResizable(True)
self.scrollarea.setFrameStyle(0)
self.scrollarea.setViewportMargins(2, 2, 2, 2)
self.scrollarea.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
self.scrollarea.setMinimumWidth(self._min_scrollbar_width)
# Set the vertical scrollbar explicitly.
# This is required to avoid a "RuntimeError: no access to protected
# functions or signals for objects not created from Python" in Linux.
self.scrollarea.setVerticalScrollBar(QScrollBar())
# Install an event filter on the scrollbar.
self.scrollarea.installEventFilter(self)
self.layout().setContentsMargins(0, 0, 0, 0)
self.layout().setSpacing(0)
return self.scrollarea
def set_figureviewer(self, figure_viewer):
"""Set the namespace for the FigureViewer."""
self.figure_viewer = figure_viewer
def eventFilter(self, widget, event):
"""
An event filter to trigger an update of the thumbnails size so that
their width fit that of the scrollarea and to remap some key press
events to mimick navigational behaviour of a Qt widget list.
"""
if event.type() == QEvent.KeyPress:
key = event.key()
if key == Qt.Key_Up:
self.go_previous_thumbnail()
return True
elif key == Qt.Key_Down:
self.go_next_thumbnail()
return True
if event.type() == QEvent.Resize:
self._update_thumbnail_size()
return super().eventFilter(widget, event)
# ---- Save Figure
def save_all_figures_as(self):
"""Save all the figures to a file."""
self.sig_redirect_stdio_requested.emit(False)
dirname = getexistingdirectory(self, 'Save all figures',
self.save_dir)
self.sig_redirect_stdio_requested.emit(True)
if dirname:
self.sig_save_dir_changed.emit(dirname)
return self.save_all_figures_todir(dirname)
def save_all_figures_todir(self, dirname):
"""Save all figure in dirname."""
fignames = []
figname_root = ('Figure ' +
datetime.datetime.now().strftime('%Y-%m-%d %H%M%S'))
for thumbnail in self._thumbnails:
fig = thumbnail.canvas.fig
fmt = thumbnail.canvas.fmt
fext = {'image/png': '.png',
'image/jpeg': '.jpg',
'image/svg+xml': '.svg'}[fmt]
figname = get_unique_figname(dirname, figname_root, fext,
start_at_zero=True)
save_figure_tofile(fig, fmt, figname)
fignames.append(figname)
return fignames
def save_current_figure_as(self):
"""Save the currently selected figure."""
if self.current_thumbnail is not None:
self.save_figure_as(self.current_thumbnail.canvas.fig,
self.current_thumbnail.canvas.fmt)
def save_thumbnail_figure_as(self, thumbnail):
"""Save the currently selected figure."""
self.save_figure_as(thumbnail.canvas.fig, thumbnail.canvas.fmt)
def save_figure_as(self, fig, fmt):
"""Save the figure to a file."""
fext, ffilt = {
'image/png': ('.png', 'PNG (*.png)'),
'image/jpeg': ('.jpg', 'JPEG (*.jpg;*.jpeg;*.jpe;*.jfif)'),
'image/svg+xml': ('.svg', 'SVG (*.svg);;PNG (*.png)')}[fmt]
figname = get_unique_figname(
self.save_dir,
'Figure ' + datetime.datetime.now().strftime('%Y-%m-%d %H%M%S'),
fext)
self.sig_redirect_stdio_requested.emit(False)
fname, fext = getsavefilename(
parent=self.parent(), caption='Save Figure',
basedir=figname, filters=ffilt,
selectedfilter='', options=None)
self.sig_redirect_stdio_requested.emit(True)
if fname:
self.sig_save_dir_changed.emit(osp.dirname(fname))
save_figure_tofile(fig, fmt, fname)
# ---- Thumbails Handlers
def _calculate_figure_canvas_width(self):
"""
Calculate the width the thumbnails need to have to fit the scrollarea.
"""
extra_padding = 10 if sys.platform == 'darwin' else 0
figure_canvas_width = (
self.scrollarea.width() -
2 * self.lineWidth() -
self.scrollarea.viewportMargins().left() -
self.scrollarea.viewportMargins().right() -
extra_padding -
self.scrollarea.verticalScrollBar().sizeHint().width()
)
figure_canvas_width = figure_canvas_width - 6
return figure_canvas_width
def _setup_thumbnail_size(self, thumbnail):
"""
Scale the thumbnail's canvas size so that it fits the thumbnail
scrollbar's width.
"""
max_canvas_size = self._calculate_figure_canvas_width()
thumbnail.scale_canvas_size(max_canvas_size)
def _update_thumbnail_size(self):
"""
Update the thumbnails size so that their width fit that of
the scrollarea.
"""
# NOTE: We hide temporarily the thumbnails to prevent a repaint of
# each thumbnail as soon as their size is updated in the loop, which
# causes some flickering of the thumbnail scrollbar resizing animation.
# Once the size of all the thumbnails has been updated, we show them
# back so that they are repainted all at once instead of one after the
# other. This is just a trick to make the resizing animation of the
# thumbnail scrollbar look smoother.
self.view.hide()
for thumbnail in self._thumbnails:
self._setup_thumbnail_size(thumbnail)
self.view.show()
def show_context_menu(self, point, thumbnail):
"""
Emit global positioned point and thumbnail for context menu request.
"""
point = thumbnail.canvas.mapToGlobal(point)
self.sig_context_menu_requested.emit(point, thumbnail)
def add_thumbnail(self, fig, fmt):
"""
Add a new thumbnail to that thumbnail scrollbar.
"""
thumbnail = FigureThumbnail(
parent=self, background_color=self.background_color)
thumbnail.canvas.load_figure(fig, fmt)
thumbnail.sig_canvas_clicked.connect(self.set_current_thumbnail)
thumbnail.sig_remove_figure_requested.connect(self.remove_thumbnail)
thumbnail.sig_save_figure_requested.connect(self.save_figure_as)
thumbnail.sig_context_menu_requested.connect(
lambda point: self.show_context_menu(point, thumbnail))
self._thumbnails.append(thumbnail)
self._new_thumbnail_added = True
self.scene.setRowStretch(self.scene.rowCount() - 1, 0)
self.scene.addWidget(thumbnail, self.scene.rowCount() - 1, 0)
self.scene.setRowStretch(self.scene.rowCount(), 100)
self.set_current_thumbnail(thumbnail)
thumbnail.show()
self._setup_thumbnail_size(thumbnail)
def remove_current_thumbnail(self):
"""Remove the currently selected thumbnail."""
if self.current_thumbnail is not None:
self.remove_thumbnail(self.current_thumbnail)
def remove_all_thumbnails(self):
"""Remove all thumbnails."""
for thumbnail in self._thumbnails:
thumbnail.sig_canvas_clicked.disconnect()
thumbnail.sig_remove_figure_requested.disconnect()
thumbnail.sig_save_figure_requested.disconnect()
self.layout().removeWidget(thumbnail)
thumbnail.setParent(None)
thumbnail.hide()
thumbnail.close()
self._thumbnails = []
self.current_thumbnail = None
self.figure_viewer.figcanvas.clear_canvas()
def remove_thumbnail(self, thumbnail):
"""Remove thumbnail."""
if thumbnail in self._thumbnails:
index = self._thumbnails.index(thumbnail)
# Disconnect signals
try:
thumbnail.sig_canvas_clicked.disconnect()
thumbnail.sig_remove_figure_requested.disconnect()
thumbnail.sig_save_figure_requested.disconnect()
except TypeError:
pass
if thumbnail in self._thumbnails:
self._thumbnails.remove(thumbnail)
# Select a new thumbnail if any :
if thumbnail == self.current_thumbnail:
if len(self._thumbnails) > 0:
self.set_current_index(
min(index, len(self._thumbnails) - 1)
)
else:
self.figure_viewer.figcanvas.clear_canvas()
self.current_thumbnail = None
# Hide and close thumbnails
self.layout().removeWidget(thumbnail)
thumbnail.hide()
thumbnail.close()
# See: spyder-ide/spyder#12459
QTimer.singleShot(
150, lambda: self._remove_thumbnail_parent(thumbnail))
def _remove_thumbnail_parent(self, thumbnail):
try:
thumbnail.setParent(None)
except RuntimeError:
# Omit exception in case the thumbnail has been garbage-collected
pass
def set_current_index(self, index):
"""Set the currently selected thumbnail by its index."""
self.set_current_thumbnail(self._thumbnails[index])
def get_current_index(self):
"""Return the index of the currently selected thumbnail."""
try:
return self._thumbnails.index(self.current_thumbnail)
except ValueError:
return -1
def set_current_thumbnail(self, thumbnail):
"""Set the currently selected thumbnail."""
if self.current_thumbnail == thumbnail:
return
if self.current_thumbnail is not None:
self.current_thumbnail.highlight_canvas(False)
self.current_thumbnail = thumbnail
self.figure_viewer.load_figure(
thumbnail.canvas.fig, thumbnail.canvas.fmt)
self.current_thumbnail.highlight_canvas(True)
def go_previous_thumbnail(self):
"""Select the thumbnail previous to the currently selected one."""
if self.current_thumbnail is not None:
index = self._thumbnails.index(self.current_thumbnail) - 1
index = index if index >= 0 else len(self._thumbnails) - 1
self.set_current_index(index)
self.scroll_to_item(index)
def go_next_thumbnail(self):
"""Select thumbnail next to the currently selected one."""
if self.current_thumbnail is not None:
index = self._thumbnails.index(self.current_thumbnail) + 1
index = 0 if index >= len(self._thumbnails) else index
self.set_current_index(index)
self.scroll_to_item(index)
def scroll_to_item(self, index):
"""Scroll to the selected item of ThumbnailScrollBar."""
spacing_between_items = self.scene.verticalSpacing()
height_view = self.scrollarea.viewport().height()
height_item = self.scene.itemAt(index).sizeHint().height()
height_view_excluding_item = max(0, height_view - height_item)
height_of_top_items = spacing_between_items
for i in range(index):
item = self.scene.itemAt(i)
height_of_top_items += item.sizeHint().height()
height_of_top_items += spacing_between_items
pos_scroll = height_of_top_items - height_view_excluding_item // 2
vsb = self.scrollarea.verticalScrollBar()
vsb.setValue(pos_scroll)
def _scroll_to_newest_item(self, vsb_min, vsb_max):
"""
Scroll to the newest item added to the thumbnail scrollbar.
Note that this method is called each time the rangeChanged signal
is emitted by the scrollbar.
"""
if self._new_thumbnail_added:
self._new_thumbnail_added = False
self.scrollarea.verticalScrollBar().setValue(vsb_max)
# ---- ScrollBar Handlers
def go_up(self):
"""Scroll the scrollbar of the scrollarea up by a single step."""
vsb = self.scrollarea.verticalScrollBar()
vsb.setValue(int(vsb.value() - vsb.singleStep()))
def go_down(self):
"""Scroll the scrollbar of the scrollarea down by a single step."""
vsb = self.scrollarea.verticalScrollBar()
vsb.setValue(int(vsb.value() + vsb.singleStep()))
class FigureThumbnail(QWidget):
"""
A widget that consists of a FigureCanvas, a side toolbar, and a context
menu that is used to show preview of figures in the ThumbnailScrollBar.
"""
sig_canvas_clicked = Signal(object)
"""
This signal is emitted when the figure canvas is clicked.
Parameters
----------
figure_thumbnail: spyder.plugins.plots.widget.figurebrowser.FigureThumbnail
The clicked figure thumbnail.
"""
sig_remove_figure_requested = Signal(object)
"""
This signal is emitted to request the removal of a figure thumbnail.
Parameters
----------
figure_thumbnail: spyder.plugins.plots.widget.figurebrowser.FigureThumbnail
The figure thumbnail to remove.
"""
sig_save_figure_requested = Signal(object, str)
"""
This signal is emitted to request the saving of a figure thumbnail.
Parameters
----------
figure_thumbnail: spyder.plugins.plots.widget.figurebrowser.FigureThumbnail
The figure thumbnail to save.
format: str
The image format to use when saving the image. One of "image/png",
"image/jpeg" and "image/svg+xml".
"""
sig_context_menu_requested = Signal(QPoint)
"""
This signal is emitted to request a context menu.
Parameters
----------
point: QPoint
The QPoint in global coordinates where the menu was requested.
"""
def __init__(self, parent=None, background_color=None):
super().__init__(parent)
self.canvas = FigureCanvas(parent=self,
background_color=background_color)
self.canvas.sig_context_menu_requested.connect(
self.sig_context_menu_requested)
self.canvas.installEventFilter(self)
self.setup_gui()
def setup_gui(self):
"""Setup the main layout of the widget."""
layout = QGridLayout(self)
layout.setContentsMargins(0, 0, 0, 0)
layout.addWidget(self.canvas, 0, 0, Qt.AlignCenter)
layout.setSizeConstraint(layout.SetFixedSize)
def highlight_canvas(self, highlight):
"""
Set a colored frame around the FigureCanvas if highlight is True.
"""
if highlight:
# Highlighted figure is not clear in dark mode with blue color.
# See spyder-ide/spyder#10255.
self.canvas.setStyleSheet(
"FigureCanvas{border: 2px solid %s;}" %
QStylePalette.COLOR_ACCENT_3
)
else:
self.canvas.setStyleSheet("FigureCanvas{}")
def scale_canvas_size(self, max_canvas_size):
"""
Scale this thumbnail canvas size, while respecting its associated
figure dimension ratio.
"""
fwidth = self.canvas.fwidth
fheight = self.canvas.fheight
if fheight != 0:
if fwidth / fheight > 1:
canvas_width = max_canvas_size
canvas_height = canvas_width / fwidth * fheight
else:
canvas_height = max_canvas_size
canvas_width = canvas_height / fheight * fwidth
self.canvas.setFixedSize(int(canvas_width), int(canvas_height))
self.layout().setColumnMinimumWidth(0, max_canvas_size)
def eventFilter(self, widget, event):
"""
A filter that is used to send a signal when the figure canvas is
clicked.
"""
if event.type() == QEvent.MouseButtonPress:
if event.button() == Qt.LeftButton:
self.sig_canvas_clicked.emit(self)
return super().eventFilter(widget, event)
class FigureCanvas(QFrame):
"""
A basic widget on which can be painted a custom png, jpg, or svg image.
"""
sig_context_menu_requested = Signal(QPoint)
"""
This signal is emitted to request a context menu.
Parameters
----------
point: QPoint
The QPoint in global coordinates where the menu was requested.
"""
def __init__(self, parent=None, background_color=None):
super().__init__(parent)
self.setLineWidth(2)
self.setMidLineWidth(1)
self.setObjectName("figcanvas")
self.setStyleSheet(
"#figcanvas {background-color:" + str(background_color) + "}")
self.fig = None
self.fmt = None
self.fwidth, self.fheight = 200, 200
self._blink_flag = False
self.setContextMenuPolicy(Qt.CustomContextMenu)
self.customContextMenuRequested.connect(
self.sig_context_menu_requested)
@Slot()
def copy_figure(self):
"""Copy figure to clipboard."""
if self.fmt in ['image/png', 'image/jpeg']:
qpixmap = QPixmap()
qpixmap.loadFromData(self.fig, self.fmt.upper())
QApplication.clipboard().setImage(qpixmap.toImage())
elif self.fmt == 'image/svg+xml':
svg_to_clipboard(self.fig)
else:
return
self.blink_figure()
def blink_figure(self):
"""Blink figure once."""
if self.fig:
self._blink_flag = not self._blink_flag
self.repaint()
if self._blink_flag:
timer = QTimer()
timer.singleShot(40, self.blink_figure)
def clear_canvas(self):
"""Clear the figure that was painted on the widget."""
self.fig = None
self.fmt = None
self._qpix_scaled = None
self.repaint()
def load_figure(self, fig, fmt):
"""
Load the figure from a png, jpg, or svg image, convert it in
a QPixmap, and force a repaint of the widget.
"""
self.fig = fig
self.fmt = fmt
if fmt in ['image/png', 'image/jpeg']:
self._qpix_orig = QPixmap()
self._qpix_orig.loadFromData(fig, fmt.upper())
elif fmt == 'image/svg+xml':
self._qpix_orig = QPixmap(svg_to_image(fig))
self._qpix_scaled = self._qpix_orig
self.fwidth = self._qpix_orig.width()
self.fheight = self._qpix_orig.height()
def paintEvent(self, event):
"""Qt method override to paint a custom image on the Widget."""
super().paintEvent(event)
# Prepare the rect on which the image is going to be painted.
fw = self.frameWidth()
rect = QRect(0 + fw, 0 + fw,
self.size().width() - 2 * fw,
self.size().height() - 2 * fw)
if self.fig is None or self._blink_flag:
return
# Prepare the scaled qpixmap to paint on the widget.
if (self._qpix_scaled is None or
self._qpix_scaled.size().width() != rect.width()):
if self.fmt in ['image/png', 'image/jpeg']:
self._qpix_scaled = self._qpix_orig.scaledToWidth(
rect.width(), mode=Qt.SmoothTransformation)
elif self.fmt == 'image/svg+xml':
self._qpix_scaled = QPixmap(svg_to_image(
self.fig, rect.size()))
if self._qpix_scaled is not None:
# Paint the image on the widget.
qp = QPainter()
qp.begin(self)
qp.drawPixmap(rect, self._qpix_scaled)
qp.end()