Source code for plotpy.widgets.colormap.manager

# -*- coding: utf-8 -*-
#
# Licensed under the terms of the BSD 3-Clause
# (see plotpy/LICENSE for details)

"""
Colormap manager
----------------

This module provides a fully featured colormap manager widget and dialog window
that allows to select, edit and save colormaps (existing or new).


Reference
~~~~~~~~~

.. autoclass:: ColorMapNameEdit
    :members:
.. autoclass:: ColorMapManager
    :members:
"""

from __future__ import annotations

from copy import deepcopy

import qtpy.QtCore as QC
import qtpy.QtGui as QG
import qtpy.QtWidgets as QW
from guidata.configtools import get_icon
from guidata.env import execenv
from guidata.qthelpers import exec_dialog

from plotpy.config import _
from plotpy.mathutils.colormap import (
    ALL_COLORMAPS,
    CUSTOM_COLORMAPS,
    DEFAULT_COLORMAPS,
    LARGE_ICON_HEIGHT,
    LARGE_ICON_ORIENTATION,
    LARGE_ICON_WIDTH,
    add_cmap,
    build_icon_from_cmap,
    cmap_exists,
    delete_cmap,
    get_cmap,
)
from plotpy.widgets.colormap.editor import ColorMapEditor
from plotpy.widgets.colormap.widget import EditableColormap


[docs] class ColorMapNameEdit(QW.QDialog): """Dialog box to enter a colormap name. Args: parent: parent QWidget. Defaults to None. title: dialog box title. Defaults to "". name: default colormap name. Defaults to "". """ def __init__( self, parent: QW.QWidget | None = None, title: str = "", name: str = "" ) -> None: super().__init__(parent) self.setWindowTitle(title) label = QW.QLabel(_("Enter a colormap name:")) self._edit = QW.QLineEdit() regexp = QC.QRegularExpression("[1-9a-zA-Z_]*") self._edit.setValidator(QG.QRegularExpressionValidator(regexp)) self._edit.setText(name) self._edit.setToolTip( _( "New colormap name cannot contain special characters " "except underscores (_)." ) ) bbox = QW.QDialogButtonBox(QW.QDialogButtonBox.Ok | QW.QDialogButtonBox.Cancel) bbox.accepted.connect(self.accept) bbox.rejected.connect(self.reject) hlayout = QW.QHBoxLayout() hlayout.addWidget(label) hlayout.addWidget(self._edit) dialog_layout = QW.QVBoxLayout() dialog_layout.addLayout(hlayout) dialog_layout.addWidget(bbox) self.setLayout(dialog_layout)
[docs] def get_colormap_name(self) -> str: """Return the colormap name entered in the QLineEdit. Returns: colormap name """ return self._edit.text()
[docs] @classmethod def edit( cls, parent: QW.QWidget | None = None, title: str = "", name: str = "" ) -> str | None: """Open the dialog box and return the colormap name entered in the QLineEdit. Args: parent: parent QWidget. Defaults to None. title: dialog box title. Defaults to "". name: default colormap name. Defaults to "". Returns: colormap name, or None if the dialog box was canceled. """ dialog = cls(parent, title, name) if exec_dialog(dialog): return dialog.get_colormap_name() return None
[docs] class ColorMapManager(QW.QDialog): """Colormap manager dialog box. Allows to select, edit and save colormaps. Args: parent: parent QWidget. Defaults to None. active_colormap: name of the active colormap. .. note:: The active colormap is the colormap that will be selected by default when the dialog box is opened. If the colormap does not exist (or if None is provided), the first colormap in the list will be selected by default. The active colormap cannot be removed. If the active colormap is a custom colormap, the remove button will be enabled but a dialog box will warn the user that the colormap cannot be removed. """ SIG_APPLY_COLORMAP = QC.Signal(str) def __init__( self, parent: QW.QWidget | None = None, active_colormap: str | None = None, ) -> None: super().__init__(parent) self.setWindowIcon(get_icon("cmap_edit.png")) self.setWindowTitle(_("Colormap manager")) self.active_cmap_name = default_colormap = active_colormap self.__returned_colormap: EditableColormap | None = None if default_colormap is None or not cmap_exists(default_colormap, ALL_COLORMAPS): default_colormap = next(iter(ALL_COLORMAPS)) # Select the active colormap self._cmap_choice = QW.QComboBox() self._cmap_choice.setMaxVisibleItems(15) for cmap in ALL_COLORMAPS.values(): icon = build_icon_from_cmap( cmap, LARGE_ICON_WIDTH, LARGE_ICON_HEIGHT, LARGE_ICON_ORIENTATION, 1 ) self._cmap_choice.addItem(icon, cmap.name, cmap) self._cmap_choice.setIconSize(QC.QSize(LARGE_ICON_WIDTH, LARGE_ICON_HEIGHT)) self._cmap_choice.setCurrentText(default_colormap) add_btn = QW.QPushButton(get_icon("edit_add.png"), _("Add") + "...") add_btn.clicked.connect(self.add_colormap) self._remove_btn = QW.QPushButton(get_icon("delete.png"), _("Remove") + "...") is_custom_cmap = cmap_exists(default_colormap, CUSTOM_COLORMAPS) self._remove_btn.setEnabled(is_custom_cmap) self._remove_btn.clicked.connect(self.remove_colormap) select_gbox = QW.QGroupBox(_("Select or create a colormap")) select_label = QW.QLabel(_("Colormap presets:")) select_gbox_layout = QW.QHBoxLayout() select_gbox_layout.addWidget(select_label) select_gbox_layout.addWidget(self._cmap_choice) select_gbox_layout.addSpacing(10) select_gbox_layout.addWidget(add_btn) select_gbox_layout.addWidget(self._remove_btn) select_gbox.setLayout(select_gbox_layout) # Edit the selected colormap current_cmap = self._cmap_choice.currentData() # This test is necessary because the currentData method returns a QVariant # object, which may be handled differently by PyQt5, PySide2 and PySide6. # This is an attempt to fix an issue with PySide6 (segfault). if isinstance(current_cmap, EditableColormap): current_cmap = deepcopy(current_cmap) else: current_cmap = None self.colormap_editor = ColorMapEditor(self, colormap=current_cmap) self._edit_gbox = QW.QGroupBox(_("Edit the selected colormap")) edit_gbox_layout = QW.QVBoxLayout() edit_gbox_layout.setContentsMargins(0, 0, 0, 0) edit_gbox_layout.addWidget(self.colormap_editor) self._edit_gbox.setLayout(edit_gbox_layout) self._edit_gbox.setCheckable(True) self._edit_gbox.setChecked(False) self.colormap_editor.colormap_widget.COLORMAP_CHANGED.connect( self._changes_not_saved ) self._cmap_choice.currentIndexChanged.connect(self.set_colormap) self.bbox = QW.QDialogButtonBox( QW.QDialogButtonBox.Apply | QW.QDialogButtonBox.Save | QW.QDialogButtonBox.Ok | QW.QDialogButtonBox.Cancel ) self._changes_saved = True self._save_btn = self.bbox.button(QW.QDialogButtonBox.Save) self._save_btn.setEnabled(False) # type: ignore self._apply_btn = self.bbox.button(QW.QDialogButtonBox.Apply) self._apply_btn.setEnabled(False) # type: ignore self.bbox.clicked.connect(self.button_clicked) dialog_layout = QW.QVBoxLayout() dialog_layout.addWidget(select_gbox) dialog_layout.addWidget(self._edit_gbox) dialog_layout.addWidget(self.bbox) self.setLayout(dialog_layout) # The dialog needs to be resizable horizontally but not vertically: self.setFixedHeight(self.sizeHint().height())
[docs] def button_clicked(self, button: QW.QAbstractButton) -> None: """Callback function to be called when a button is clicked. Args: button: button clicked """ if button is self._apply_btn: if not self.current_changes_saved and not self.save_colormap(): return self._apply_btn.setEnabled(False) self.SIG_APPLY_COLORMAP.emit(self.colormap_editor.get_colormap().name) elif button is self._save_btn: self.save_colormap() elif self.bbox.buttonRole(button) == QW.QDialogButtonBox.AcceptRole: self.accept() else: self.reject()
def _changes_not_saved(self) -> None: """Callback function to be called when the colormap is modified. Enables the save button and sets the current_changes_saved attribute to False.""" self._save_btn.setEnabled(True) # type: ignore self._apply_btn.setEnabled(True) # type: ignore self._changes_saved = False @property def current_changes_saved(self) -> bool: """Getter to know if the current colormap has been saved or not. Returns: True if the current colormap has been saved, False otherwise. """ return self._changes_saved
[docs] def set_colormap(self, index: int) -> None: """Set the current colormap to the value present at the given index in the QComboBox. Makes a copy of the colormap object so the ColorMapEditor does not mutate the original colormap object. Args: index: index of the colormap in the QComboBox. """ cmap_copy: EditableColormap = deepcopy(self._cmap_choice.itemData(index)) self.colormap_editor.set_colormap(cmap_copy) is_custom_cmap = cmap_exists(cmap_copy.name, CUSTOM_COLORMAPS) self._remove_btn.setEnabled(is_custom_cmap) self._changes_saved = True is_new_colormap = not cmap_exists(cmap_copy.name) self._save_btn.setEnabled(is_new_colormap) # type: ignore
[docs] def get_colormap(self) -> EditableColormap | None: """Return the selected colormap object. Returns: selected colormap object """ return self.__returned_colormap
def __get_new_colormap_name(self, title: str, name: str) -> str | None: """Return a new colormap name that does not already exists in the list of colormaps. Args: title: title of the current action name: colormap name to check Returns: new colormap name, or None if the user canceled the action. """ new_name = name while True: new_name = ColorMapNameEdit.edit(self, title, new_name) if new_name is None: return None if cmap_exists(new_name, DEFAULT_COLORMAPS): QW.QMessageBox.warning( self, title, _( "Name <b>%s</b> is already used by a predefined colormap, and " "cannot be used for a custom colormap.<br><br>" "Please choose another name." ) % new_name, ) continue if cmap_exists(new_name, CUSTOM_COLORMAPS) and ( QW.QMessageBox.question( self, title, _( "Name <b>%s</b> is already used by a custom colormap.<br><br>" "Do you want to overwrite it?" ) % new_name, QW.QMessageBox.Yes | QW.QMessageBox.No, QW.QMessageBox.No, ) == QW.QMessageBox.No ): continue break return new_name
[docs] def add_colormap(self) -> None: """Create a new colormap and set it as the current colormap.""" cmap = EditableColormap(QG.QColor(0), QG.QColor(4294967295), name=_("New")) if self.save_colormap(cmap): # If the colormap save dialog was accepted, then we enable the colormap # editor group box. # Otherwise, we leave the colormap editor group box at its current state. self._edit_gbox.setChecked(True)
[docs] def remove_colormap(self) -> None: """Remove the current colormap.""" cmap = self.colormap_editor.get_colormap() if cmap is None: return name = cmap.name if name == self.active_cmap_name or cmap_exists(name, DEFAULT_COLORMAPS): if name == self.active_cmap_name: msg = _("Colormap <b>%s</b> is the active colormap.") else: msg = _("Colormap <b>%s</b> is a predefined colormap.") QW.QMessageBox.warning( self, _("Remove"), msg % name + "<br>" + _("Thus, this colormap cannot be removed."), QW.QMessageBox.Ok, ) return if execenv.unattended: # For testing purposes only if not execenv.accept_dialogs: return elif ( QW.QMessageBox.question( self, _("Remove"), _("Do you want to delete colormap <b>%s</b>?") % name, QW.QMessageBox.Yes | QW.QMessageBox.No, QW.QMessageBox.No, ) == QW.QMessageBox.No ): return delete_cmap(cmap) current_index = self._cmap_choice.currentIndex() self._cmap_choice.removeItem(current_index) self._changes_saved = True self._save_btn.setEnabled(False) self._cmap_choice.setCurrentIndex(max(current_index - 1, 0))
[docs] def save_colormap(self, cmap: EditableColormap | None = None) -> bool: """Saves the current colormap and handles the validation process. The saved colormaps can only be saved in the custom colormaps. Args: cmap: colormap to save. If None, will use the current colormap. Returns: True if the colormap has been saved, False otherwise. """ if cmap is None: cmap = self.colormap_editor.get_colormap() title = _("Save colormap") else: title = _("Add colormap") new_name = self.__get_new_colormap_name(title, cmap.name) if new_name is None: return False # Before modifying CUSTOM_COLORMAPS, save this boolean expression: is_existing_custom_cmap = cmap_exists(new_name, CUSTOM_COLORMAPS) # Take into account the case where the color map exists in custom colormaps # but not with the same case (e.g. "viridis" vs "Viridis"). So we need to # get the original name of the colormap: if is_existing_custom_cmap: new_name = get_cmap(new_name).name cmap.name = new_name add_cmap(cmap) icon = build_icon_from_cmap( cmap, LARGE_ICON_WIDTH, LARGE_ICON_HEIGHT, LARGE_ICON_ORIENTATION, 1 ) if is_existing_custom_cmap: self._cmap_choice.setCurrentText(new_name) current_index = self._cmap_choice.currentIndex() self._cmap_choice.setItemData(current_index, cmap) self._cmap_choice.setItemIcon(current_index, icon) else: self._cmap_choice.addItem(icon, new_name, cmap) self._cmap_choice.setCurrentText(new_name) self._save_btn.setEnabled(False) # type: ignore self._changes_saved = True return True
[docs] def accept(self) -> None: """Adds logic on top of the normal QDialog.accept method to handle colormap save. """ if not self.current_changes_saved and not self.save_colormap(): return self.__returned_colormap = self.colormap_editor.get_colormap() super().accept()