# -*- 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())
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()