Source code for prism._gui.widgets.preferences.custom_boxes

# -*- coding: utf-8 -*-

"""
GUI Custom Option Boxes
=======================
Provides a collection of :class:`~PyQt5.QtWidgets.QWidget` subclasses to be
used as custom option entry boxes in the
:class:`~prism._gui.widgets.preferences.OptionsDialog` class or
:class:`~prism._gui.widgets.preferences.KwargsDictDialogPage` class.

"""


# %% IMPORTS
# Built-in imports
from itertools import chain

# Package imports
import cmasher as cmr
import e13tools as e13
from matplotlib import cm, rcParams
from matplotlib.colors import BASE_COLORS, CSS4_COLORS, to_rgba
import matplotlib.pyplot as plt
import numpy as np
from qtpy import QtCore as QC, QtGui as QG, QtWidgets as QW
from sortedcontainers import SortedDict as sdict, SortedSet as sset

# PRISM imports
from prism._docstrings import kwargs_doc, qt_slot_doc
from prism._gui.widgets import (
    BaseBox, QW_QComboBox, QW_QDoubleSpinBox, QW_QEditableComboBox, QW_QLabel,
    QW_QSpinBox, get_box_value, set_box_value)

# All declaration
__all__ = ['ColorBox', 'ColorMapBox', 'DefaultBox']


# %% CLASS DEFINITIONS
# Make class with a special box for setting the color of a plotted line
[docs]class ColorBox(BaseBox): """ Defines the :class:`~ColorBox` class. This class is used for making the 'color' entry in the :class:`~prism._gui.widgets.preferences.KwargsDictDialogPage` class. """
[docs] @e13.docstring_substitute(optional=kwargs_doc.format( 'prism._gui.widgets.core.BaseBox')) def __init__(self, *args, **kwargs): """ Initialize an instance of the :class:`~ColorBox` class. %(optional)s """ # Call super constructor super().__init__(*args, **kwargs) # Create the color box self.init()
# This function creates the color box
[docs] def init(self): """ Sets up the color box entry after it has been initialized. This function is mainly responsible for creating the color wheel and color label, that allow the user to quickly cycle through different color options. """ # Create the box layout box_layout = QW.QHBoxLayout(self) box_layout.setContentsMargins(0, 0, 0, 0) self.setToolTip("Color to be used for the corresponding plot type") # Declare the default color self.default_color = rcParams['lines.color'] # Create a color label color_label = self.create_color_label() self.color_label = color_label box_layout.addWidget(color_label) # Create a color combobox color_combobox = self.create_color_combobox() box_layout.addWidget(color_combobox) self.color_combobox = color_combobox # Set the starting color of the color box self.set_box_value(self.default_color)
# This function creates the color label
[docs] def create_color_label(self): """ Creates a special label that shows the currently selected or hovered color, and returns it. """ # Create the color label color_label = QW_QLabel() # Set some properties color_label.setFrameShape(QW.QFrame.StyledPanel) color_label.setScaledContents(True) color_label.setToolTip("Click to open the custom color picker") color_label.setSizePolicy(QW.QSizePolicy.Fixed, QW.QSizePolicy.Fixed) color_label.mousePressed.connect(self.show_colorpicker) # Return it return(color_label)
# This function creates the color combobox
[docs] def create_color_combobox(self): """ Creates a combobox that holds all default colors accepted by matplotlib and returns it. """ # Obtain the CN colors n_cyclic = len(rcParams['axes.prop_cycle']) CN_COLORS = [("C%i" % (i), "This is MPL cyclic color #%i" % (i)) for i in range(n_cyclic)] # Make tuple of all colors colors = (CN_COLORS, BASE_COLORS, CSS4_COLORS) # Determine the cumulative lengths of all four sets cum_len = np.cumsum(list(map(len, colors))) # Make combobox for colors color_box = QW_QEditableComboBox() # Fill combobox with all colors for i, color in enumerate(chain(*colors)): # If color is a tuple, it consists of (color, tooltip) if isinstance(color, tuple): color_box.addItem(color[0]) color_box.setItemData(i, color[1], QC.Qt.ToolTipRole) else: color_box.addItem(color) # Add some separators for i in reversed(cum_len[:-1]): color_box.insertSeparator(i) # Set remaining properties color_box.setToolTip("Select or type (in HEX) the color") color_box.highlighted[str].connect(self.set_color_label) color_box.popup_hidden[str].connect(self.set_color_label) color_box.currentTextChanged.connect(self.set_color) return(color_box)
# This function converts an MPL color to a QColor
[docs] @staticmethod def convert_to_qcolor(color): """ Converts a provided matplotlib color `color` to a :obj:`~PyQt5.QtGui.QColor` object. Parameters ---------- color : str The matplotlib color that must be converted. If `color` is a float string, an error will be raised, as Qt5 does not accept those. Returns ------- qcolor : :obj:`~PyQt5.QtGui.QColor` object The instance of the :class:`~PyQt5.QtGui.QColor` class that corresponds to the provided `color`. """ # If the color can be converted to a float, raise a ValueError # This is because MPL accepts float strings as valid colors try: float(color) except ValueError: pass else: raise ValueError # Obtain the RGBA values of an MPL color r, g, b, a = to_rgba(color) # Convert to Qt RGBA values color = QG.QColor( int(r*255), int(g*255), int(b*255), int(a*255)) # Return color return(color)
# This function converts a QColor to an MPL color
[docs] @staticmethod def convert_to_mpl_color(qcolor): """ Converts a provided :obj:`~PyQt5.QtGui.QColor` object `color` to a matplotlib color. Parameters ---------- qcolor : :obj:`~PyQt5.QtGui.QColor` object The instance of the :class:`~PyQt5.QtGui.QColor` class must be converted to a matplotlib color. Returns ------- color : str The corresponding matplotlib color. The returned `color` is always written in HEX. """ hexid = qcolor.name() return str(hexid)
# This function creates a pixmap of an MPL color
[docs] @staticmethod def create_color_pixmap(color, size): """ Creates a :obj:`~PyQt5.QtGui.QPixmap` object consisting of the given `color` with the provided `size`. Parameters ---------- color : str The matplotlib color that must be used for the pixmap. size : tuple The width and height dimension values of the pixmap to be created. Returns ------- pixmap : :obj:`~PyQt5.QtGui.QPixmap` object The instance of the :class:`~PyQt5.QtGui.QPixmap` class that was created from the provided `color` and `size`. """ # Obtain the RGBA values of an MPL color color = ColorBox.convert_to_qcolor(color) # Create an image object image = QG.QImage(*size, QG.QImage.Format_RGB32) # Fill the entire image with the same color image.fill(color) # Convert the image to a pixmap pixmap = QG.QPixmap.fromImage(image) # Return the pixmap return(pixmap)
# This function shows the custom color picker dialog
[docs] @QC.Slot() @e13.docstring_substitute(qt_slot=qt_slot_doc) def show_colorpicker(self): """ Shows the colorwheel picker dialog to the user, allowing for any color option to be selected. %(qt_slot)s """ # Obtain current qcolor qcolor = self.convert_to_qcolor(self.get_box_value()) # Show color dialog color = QW.QColorDialog.getColor( qcolor, parent=self, options=QW.QColorDialog.DontUseNativeDialog) # If the returned color is valid, save it if color.isValid(): self.set_color(self.convert_to_mpl_color(color))
# This function sets a given color as the current color
[docs] @QC.Slot(str) @e13.docstring_substitute(qt_slot=qt_slot_doc) def set_color(self, color): """ Sets the current color to the provided `color`, and updates the entry in the combobox and the label accordingly. %(qt_slot)s Parameters ---------- color : str The color that needs to be used as the current color. The provided `color` can be any string that is accepted as a color by matplotlib. If `color` is invalid, it is set to the current default color instead. """ # If color can be converted to a hex integer, do so and add hash to it try: int(color, 16) except ValueError: pass else: # Make sure that color has a length of 6 if(len(color) == 6): color = "#%s" % (color) # Set the color label default_flag = self.set_color_label(color) # If default was not used, set the combobox to the proper value as well if not default_flag: set_box_value(self.color_combobox, color)
# This function sets the color of the colorlabel
[docs] @QC.Slot(str) @e13.docstring_substitute(qt_slot=qt_slot_doc) def set_color_label(self, color): """ Sets the current color label to the provided `color`. %(qt_slot)s Parameters ---------- color : str The color that needs to be used as the current color label. The provided `color` can be any string that is accepted as a color by matplotlib. If `color` is invalid, it is set to the current default color instead. Returns ------- default_flag : bool Whether or not the color label is currently set to the default color. This happens when `color` is an invalid color. """ # Try to create the pixmap of the colorlabel try: pixmap = self.create_color_pixmap(color, (70, self.color_combobox.height()-2)) default_flag = False # If that cannot be done, create the default instead except ValueError: pixmap = self.create_color_pixmap(self.default_color, (70, self.color_combobox.height()-2)) default_flag = True # Set the colorlabel self.color_label.setPixmap(pixmap) # Return if default was used or not return(default_flag)
# This function retrieves a value of this special box
[docs] def get_box_value(self): """ Returns the current (valid) color value of the color combobox. Returns ------- color : str The current valid matplotlib color value. """ # Obtain the value color = get_box_value(self.color_combobox) # Try to convert this to QColor try: self.convert_to_qcolor(color) # If this fails, return the default color except ValueError: return(self.default_color) # Else, return the retrieved color else: return(color)
# This function sets the value of this special box
[docs] def set_box_value(self, value): """ Sets the current (default) color value to `value`. Parameters ---------- value : str The matplotlib color value that must be set for this colorbox. """ self.set_color(value) self.default_color = value self.color_combobox.lineEdit().setPlaceholderText(value)
# Make class with a special box for setting the colormap of a plotted hexbin
[docs]class ColorMapBox(BaseBox): """ Defines the :class:`~ColorMapBox` class. This class is used for making the 'cmap' entry in the :class:`~prism._gui.widgets.preferences.KwargsDictDialogPage` class. """
[docs] @e13.docstring_substitute(optional=kwargs_doc.format( 'prism._gui.widgets.core.BaseBox')) def __init__(self, *args, **kwargs): """ Initialize an instance of the :class:`~ColorMapBox` class. %(optional)s """ # Call super constructor super().__init__(*args, **kwargs) # Create the colormap box self.init()
# This function creates a combobox with colormaps def init(self): # Define set of CMasher colormaps that should be at the top cmr_cmaps = sset(['dusk', 'freeze', 'gothic', 'heat', 'rainforest', 'sunburst']) # Check that all of those colormaps are available in CMasher cmr_cmaps.intersection_update(cmr.cm.cmap_d) # Obtain a set with default MPL colormaps that should be at the top std_cmaps = sset(['cividis', 'inferno', 'magma', 'plasma', 'viridis']) # Add CMasher colormaps to it std_cmaps.update(['cmr.'+cmap for cmap in cmr_cmaps]) # Obtain reversed set of recommended colormaps std_cmaps_r = sset([cmap+'_r' for cmap in std_cmaps]) # Obtain a list with all colormaps and their reverses all_cmaps = sset([cmap for cmap in plt.colormaps() if not cmap.endswith('_r')]) all_cmaps_r = sset([cmap for cmap in plt.colormaps() if cmap.endswith('_r')]) # Gather all sets together cmaps = (std_cmaps, std_cmaps_r, all_cmaps, all_cmaps_r) # Determine the cumulative lengths of all four sets cum_len = np.cumsum(list(map(len, cmaps))) # Set the size for the colormap previews cmap_size = (100, 15) # If the colormap icons have not been created yet, do that now if not hasattr(self, 'cmap_icons'): cmap_icons = sdict() for cmap in chain(all_cmaps, all_cmaps_r): cmap_icons[cmap] = self.create_cmap_icon(cmap, cmap_size) ColorMapBox.cmap_icons = cmap_icons # Create a layout for this widget box_layout = QW.QHBoxLayout(self) box_layout.setContentsMargins(0, 0, 0, 0) self.setToolTip("Colormap to be used for the corresponding plot type") # Create a combobox for cmaps cmaps_box = QW_QComboBox() for cmap in chain(*cmaps): cmap_icon = self.cmap_icons[cmap] cmaps_box.addItem(cmap_icon, cmap) # Add some separators for i in reversed(cum_len[:-1]): cmaps_box.insertSeparator(i) cmaps_box.insertSeparator(cum_len[1]+1) # Set remaining properties set_box_value(cmaps_box, rcParams['image.cmap']) cmaps_box.setIconSize(QC.QSize(*cmap_size)) cmaps_box.currentTextChanged.connect(self.cmap_selected) # Add cmaps_box to layout box_layout.addWidget(cmaps_box) self.cmaps_box = cmaps_box # This function creates an icon of a colormap
[docs] @staticmethod def create_cmap_icon(cmap, size): """ Creates a :obj:`~PyQt5.QtGui.QIcon` object of the given `cmap` with the provided `size`. Parameters ---------- cmap : :obj:`~matplotlib.colors.Colormap` object or str The colormap for which an icon needs to be created. size : tuple A tuple containing the width and height dimension values of the icon to be created. Returns ------- icon : :obj:`~PyQt5.QtGui.QIcon` object The instance of the :class:`~PyQt5.QtGui.QIcon` class that was created from the provided `cmap` and `size`. """ # Obtain the cmap cmap = cm.get_cmap(cmap) # Obtain the RGBA values of the colormap # TODO: Figure out why setting 256 to cmap.N does not work for N > 256 x = np.linspace(0, 1, 256) rgba = cmap(x) # Convert to Qt RGBA values rgba = [QG.QColor( int(r*255), int(g*255), int(b*255), int(a*255)).rgba() for r, g, b, a in rgba] # Create an image object image = QG.QImage(256, 1, QG.QImage.Format_Indexed8) # Set the value of every pixel in this image image.setColorTable(rgba) for i in range(256): image.setPixel(i, 0, i) # Scale the image to its proper size image = image.scaled(*size) # Convert the image to a pixmap pixmap = QG.QPixmap.fromImage(image) # Convert the pixmap to an icon icon = QG.QIcon(pixmap) # Return the icon return(icon)
# This function checks a selected cmap
[docs] @QC.Slot(str) def cmap_selected(self, cmap): """ Qt slot that checks a provided `cmap` and shows an error message if `cmap` is a terrible colormap. """ # Make a tuple with terrible colormaps bad_cmaps = ('gist_ncar', 'gist_rainbow', 'gist_stern', 'jet', 'nipy_spectral') # If a terrible colormap is selected, show error message if cmap.startswith(bad_cmaps): # Create error message err_msg = ("The selected <b><i>%s</i></b> cmap is terrible for " "drawing PRISM's projection figures. To avoid " "introducing fake perceptual features, it is " "recommended to pick a <i>perceptually uniform " "sequential</i> colormap, like the ones at the top of " "this list.<br><br>" "See <a href=\"%s\">here</a> for more information on " "this subject." % (cmap, ("https://cmasher.readthedocs.io/en/latest"))) # Show error window QW.QMessageBox.warning( self, "%s WARNING" % (cmap.upper()), err_msg)
# This function retrieves a value of this special box
[docs] def get_box_value(self): """ Returns the current colormap of the colormap box. Returns ------- cmap : :obj:`~matplotlib.colors.Colormap` object The currently selected colormap. """ # Obtain the value colormap = get_box_value(self.cmaps_box) # Convert to matplotlib colormap cmap = cm.get_cmap(colormap) # Return it return(cmap)
# This function sets the value of this special box
[docs] def set_box_value(self, cmap): """ Sets the current colormap to `cmap`. Parameters ---------- cmap : :obj:`~matplotlib.colors.Colormap` object The colormap that must be used for this colormap box. """ # Obtain the name of the provided colormap name = cmap.name # Set this as the current colormap set_box_value(self.cmaps_box, name)
# Make class for the default non-standard box that allows type to be selected
[docs]class DefaultBox(BaseBox): """ Defines the :class:`~DefaultBox` class. This class is used for making a non-standard entry in the :class:`~prism._gui.widgets.preferences.KwargsDictDialogPage` class. It currently supports inputs of type bool; float; int; and str. """
[docs] @e13.docstring_substitute(optional=kwargs_doc.format( 'prism._gui.widgets.core.BaseBox')) def __init__(self, *args, **kwargs): """ Initialize an instance of the :class:`~DefaultBox` class. %(optional)s """ # Call super constructor super().__init__(*args, **kwargs) # Create the default box self.init()
# This function creates a double box with type and lineedit
[docs] def init(self): """ Sets up the non-standard default box entry after it has been initialized. This function is mainly responsible for creating the type combobox and allowing for different field boxes to be used for different value types. """ # Create the box_layout box_layout = QW.QHBoxLayout(self) box_layout.setContentsMargins(0, 0, 0, 0) self.box_layout = box_layout self.setToolTip("Enter the type and value for this unknown entry type") # Make a look-up dict for types self.type_dict = { bool: 'bool', float: 'float', int: 'int', str: 'str'} # Create a combobox for the type type_box = QW_QComboBox() type_box.addItems(self.type_dict.values()) type_box.setToolTip("Type of the entered value") type_box.setSizePolicy(QW.QSizePolicy.Fixed, QW.QSizePolicy.Fixed) type_box.currentTextChanged.connect(self.create_field_box) self.type_box = type_box # Make value box corresponding to the current type val_box = getattr(self, "create_type_%s" % (type_box.currentText()))() self.value_box = val_box # Add everything to the box_layout box_layout.addWidget(type_box) box_layout.addWidget(val_box)
# This function creates a field_box depending on the type that was selected
[docs] @QC.Slot(str) @e13.docstring_substitute(qt_slot=qt_slot_doc) def create_field_box(self, value_type): """ Creates a field box for the provided type `value_type` and replaces the current field box with it. %(qt_slot)s Parameters ---------- value_type : {'bool'; 'float'; 'int'; 'str'} The string that defines what type of field box is requested. """ # Create a widget box for the specified value_type value_box = getattr(self, "create_type_%s" % (value_type))() # Set this value_box in the layout cur_item = self.box_layout.replaceWidget(self.value_box, value_box) cur_item.widget().close() del cur_item # Save new value_box self.value_box = value_box
# This function creates the value box for bools
[docs] def create_type_bool(self): """ Creates the field box for values of type 'bool' and returns it. """ # Create a checkbox for bools bool_box = QW.QCheckBox() bool_box.setToolTip("Boolean value for this entry type") return(bool_box)
# This function creates the value box for floats
[docs] def create_type_float(self): """ Creates the field box for values of type 'float' and returns it. """ # Create a spinbox for floats float_box = QW_QDoubleSpinBox() float_box.setRange(-9999999, 9999999) float_box.setDecimals(6) float_box.setToolTip("Float value for this entry type") return(float_box)
# This function creates the value box for integers
[docs] def create_type_int(self): """ Creates the field box for values of type 'int' and returns it. """ # Create a spinbox for integers int_box = QW_QSpinBox() int_box.setRange(-9999999, 9999999) int_box.setToolTip("Integer value for this entry type") return(int_box)
# This function creates the value box for strings
[docs] def create_type_str(self): """ Creates the field box for values of type 'str' and returns it. """ # Create a lineedit for strings str_box = QW.QLineEdit() str_box.setToolTip("String value for this entry type") return(str_box)
# This function retrieves a value of this special box
[docs] def get_box_value(self): """ Returns the current value of the field box. Returns ------- value : bool, float, int or str The current value of this default box. """ return(get_box_value(self.value_box))
# This function sets the value of this special box
[docs] def set_box_value(self, value): """ Sets the value type to `type(value)` and the field value to `value`. Parameters ---------- value : bool, float, int or str The value to use for this default box. The type of `value` determines which field box must be used. """ set_box_value(self.type_box, self.type_dict[type(value)]) set_box_value(self.value_box, value)