"""Preferences widget for psyplot_gui
This module defines the :class:`Preferences` widget that creates an interface
to the rcParams of psyplot and psyplot_gui"""
# SPDX-FileCopyrightText: 2016-2024 University of Lausanne
# SPDX-FileCopyrightText: 2020-2021 Helmholtz-Zentrum Geesthacht
# SPDX-FileCopyrightText: 2021-2024 Helmholtz-Zentrum hereon GmbH
#
# SPDX-License-Identifier: LGPL-3.0-only
from warnings import warn
import yaml
from psyplot.config.rcsetup import RcParams, psyplot_fname
from psyplot.config.rcsetup import rcParams as psy_rcParams
from psyplot_gui import rcParams as rcParams
from psyplot_gui.common import get_icon
from psyplot_gui.compat.qtcompat import (
QAbstractItemView,
QAction,
QDialog,
QDialogButtonBox,
QFileDialog,
QHBoxLayout,
QIcon,
QLabel,
QListView,
QListWidget,
QListWidgetItem,
QMenu,
QPushButton,
QScrollArea,
QSplitter,
QStackedWidget,
Qt,
QtCore,
QTextEdit,
QtGui,
QToolButton,
QTreeWidget,
QTreeWidgetItem,
QVBoxLayout,
QWidget,
asstring,
with_qt5,
)
[docs]
class ConfigPage(object):
"""An abstract base class for configuration pages"""
#: A signal that shall be emitted if the validation state changes
validChanged = QtCore.pyqtSignal(bool)
#: A signal that is emitted if changes are propsed. The signal should be
#: emitted with the instance of the page itself
propose_changes = QtCore.pyqtSignal(object)
#: The title for the config page
title = None
#: The icon of the page
icon = None
#: :class:`bool` that is True, if the changes in this ConfigPage are set
#: immediately
auto_updates = False
@property
def is_valid(self):
"""Check whether the page is valid"""
raise NotImplementedError
@property
def changed(self):
"""Check whether the preferences will change"""
raise NotImplementedError
[docs]
def initialize(self):
"""Initialize the page"""
raise NotImplementedError
[docs]
def apply_changes(self):
"""Apply the planned changes"""
raise NotImplementedError
[docs]
class RcParamsTree(QTreeWidget):
"""A QTreeWidget that can be used to display a RcParams instance
This widget is populated by a :class:`psyplot.config.rcsetup.RcParams`
instance and displays whether the values are valid or not"""
#: A signal that shall be emitted if the validation state changes
validChanged = QtCore.pyqtSignal(bool)
#: A signal that is emitted if changes are propsed. It is either emitted
#: with the parent of this instance (if this is not None) or with the
#: instance itself
propose_changes = QtCore.pyqtSignal(object)
#: The :class:`~psyplot.config.rcsetup.RcParams` to display
rc = None
#: list of :class:`bool`. A boolean for each rcParams key that states
#: whether the proposed value is valid or not
valid = []
value_col = 2
def __init__(self, rcParams, validators, descriptions, *args, **kwargs):
"""
Parameters
----------
rcParams: dict
The dictionary that contains the rcParams
validators: dict
A mapping from the `rcParams` key to the validation function for
the corresponding value
descriptions: dict
A mapping from the `rcParams` key to it's description
See Also
--------
psyplot.config.rcsetup.RcParams
psyplot.config.rcsetup.RcParams.validate
psyplot.config.rcsetup.RcParams.descriptions
"""
super(RcParamsTree, self).__init__(*args, **kwargs)
self.rc = rcParams
self.validators = validators
self.descriptions = descriptions
self.setContextMenuPolicy(Qt.CustomContextMenu)
self.customContextMenuRequested.connect(self.open_menu)
self.setColumnCount(self.value_col + 1)
self.setHeaderLabels(["RcParams key", "", "Value"])
@property
def is_valid(self):
"""True if all the proposed values in this tree are valid"""
return all(self.valid)
@property
def top_level_items(self):
"""An iterator over the topLevelItems in this tree"""
return map(self.topLevelItem, range(self.topLevelItemCount()))
[docs]
def initialize(self):
"""Fill the items of the :attr:`rc` into the tree"""
rcParams = self.rc
descriptions = self.descriptions
self.valid = [True] * len(rcParams)
validators = self.validators
vcol = self.value_col
for i, (key, val) in enumerate(sorted(rcParams.items())):
item = QTreeWidgetItem(0)
item.setText(0, key)
item.setToolTip(0, key)
item.setIcon(1, QIcon(get_icon("valid.png")))
desc = descriptions.get(key)
if desc:
item.setText(vcol, desc)
item.setToolTip(vcol, desc)
child = QTreeWidgetItem(0)
item.addChild(child)
self.addTopLevelItem(item)
editor = QTextEdit(self)
# set maximal height of the editor to 3 rows
editor.setMaximumHeight(
4 * QtGui.QFontMetrics(editor.font()).height()
)
editor.setPlainText(yaml.dump(val))
self.setItemWidget(child, vcol, editor)
editor.textChanged.connect(
self.set_icon_func(i, item, validators[key])
)
self.resizeColumnToContents(0)
self.resizeColumnToContents(1)
[docs]
def set_icon_func(self, i, item, validator):
"""Create a function to change the icon of one topLevelItem
This method creates a function that can be called when the value of an
item changes to display it's valid state. The returned function changes
the icon of the given topLevelItem depending on
whether the proposed changes are valid or not and it modifies the
:attr:`valid` attribute accordingly
Parameters
----------
i: int
The index of the topLevelItem
item: QTreeWidgetItem
The topLevelItem
validator: func
The validation function
Returns
-------
function
The function that can be called to set the correct icon"""
def func():
editor = self.itemWidget(item.child(0), self.value_col)
s = asstring(editor.toPlainText())
try:
val = yaml.load(s, Loader=yaml.Loader)
except Exception as e:
item.setIcon(1, QIcon(get_icon("warning.png")))
item.setToolTip(1, "Could not parse yaml code: %s" % e)
self.set_valid(i, False)
return
try:
validator(val)
except Exception as e:
item.setIcon(1, QIcon(get_icon("invalid.png")))
item.setToolTip(1, "Wrong value: %s" % e)
self.set_valid(i, False)
else:
item.setIcon(1, QIcon(get_icon("valid.png")))
self.set_valid(i, True)
self.propose_changes.emit(self.parent() or self)
return func
[docs]
def set_valid(self, i, b):
"""Set the validation status
If the validation status changed compared to the old one, the
:attr:`validChanged` signal is emitted
Parameters
----------
i: int
The index of the topLevelItem
b: bool
The valid state of the item
"""
old = self.is_valid
self.valid[i] = b
new = self.is_valid
if new is not old:
self.validChanged.emit(new)
[docs]
def changed_rc(self, use_items=False):
"""Iterate over the changed rcParams
Parameters
----------
use_items: bool
If True, the topLevelItems are used instead of the keys
Yields
------
QTreeWidgetItem or str
The item identifier
object
The proposed value"""
def equals(item, key, val, orig):
return val != orig
for t in self._get_rc(equals):
yield t[0 if use_items else 1], t[2]
[docs]
def selected_rc(self, use_items=False):
"""Iterate over the selected rcParams
Parameters
----------
use_items: bool
If True, the topLevelItems are used instead of the keys
Yields
------
QTreeWidgetItem or str
The item identifier
object
The proposed value"""
def is_selected(item, key, val, orig):
return item.isSelected()
for t in self._get_rc(is_selected):
yield t[0 if use_items else 1], t[2]
def _get_rc(self, filter_func=None):
"""Iterate over the rcParams
This function applies the given `filter_func` to check whether the
item should be included or not
Parameters
----------
filter_func: function
A function that accepts the following arguments:
item
The QTreeWidgetItem
key
The rcParams key
val
The proposed value
orig
The current value
Yields
------
QTreeWidgetItem
The corresponding topLevelItem
str
The rcParams key
object
The proposed value
object
The current value
"""
def no_check(item, key, val, orig):
return True
rc = self.rc
filter_func = filter_func or no_check
for item in self.top_level_items:
key = asstring(item.text(0))
editor = self.itemWidget(item.child(0), self.value_col)
val = yaml.load(asstring(editor.toPlainText()), Loader=yaml.Loader)
try:
val = rc.validate[key](val)
except Exception:
pass
try:
include = filter_func(item, key, val, rc[key])
except Exception:
warn("Could not check state for %s key" % key, RuntimeWarning)
else:
if include:
yield (item, key, val, rc[key])
[docs]
def apply_changes(self):
"""Update the :attr:`rc` with the proposed changes"""
new = dict(self.changed_rc())
if new != self.rc:
self.rc.update(new)
[docs]
def select_changes(self):
"""Select all the items that changed comparing to the current rcParams"""
for item, val in self.changed_rc(True):
item.setSelected(True)
[docs]
class Prefences(QDialog):
"""Preferences dialog"""
@property
def bt_apply(self):
return self.bbox.button(QDialogButtonBox.Apply)
@property
def pages(self):
return map(self.get_page, range(self.pages_widget.count()))
def __init__(self, main=None):
super(Prefences, self).__init__(parent=main)
self.setWindowTitle("Preferences")
# Widgets
self.pages_widget = QStackedWidget()
self.contents_widget = QListWidget()
self.bt_reset = QPushButton("Reset to defaults")
self.bt_load_plugins = QPushButton("Load plugin pages")
self.bt_load_plugins.setToolTip(
"Load the rcParams for the plugins in separate pages"
)
self.bbox = bbox = QDialogButtonBox(
QDialogButtonBox.Ok
| QDialogButtonBox.Apply
| QDialogButtonBox.Cancel
)
# Widgets 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("Preferences")
self.contents_widget.setMovement(QListView.Static)
self.contents_widget.setSpacing(1)
self.contents_widget.setCurrentRow(0)
# Layout
hsplitter = QSplitter()
hsplitter.addWidget(self.contents_widget)
hsplitter.addWidget(self.pages_widget)
hsplitter.setStretchFactor(1, 1)
btnlayout = QHBoxLayout()
btnlayout.addWidget(self.bt_reset)
btnlayout.addWidget(self.bt_load_plugins)
btnlayout.addStretch(1)
btnlayout.addWidget(bbox)
vlayout = QVBoxLayout()
vlayout.addWidget(hsplitter)
vlayout.addLayout(btnlayout)
self.setLayout(vlayout)
# Signals and slots
if main is not None:
self.bt_reset.clicked.connect(main.reset_rcParams)
self.bt_load_plugins.clicked.connect(self.load_plugin_pages)
self.pages_widget.currentChanged.connect(self.current_page_changed)
self.contents_widget.currentRowChanged.connect(
self.pages_widget.setCurrentIndex
)
bbox.accepted.connect(self.accept)
bbox.rejected.connect(self.reject)
self.bt_apply.clicked.connect(self.apply_clicked)
self.bt_apply.setEnabled(False)
[docs]
def set_current_index(self, index):
"""Set current page index"""
self.contents_widget.setCurrentRow(index)
[docs]
def current_page_changed(self, index):
configpage = self.get_page(index)
self.bt_apply.setVisible(not configpage.auto_updates)
self.check_changes(configpage)
[docs]
def get_page(self, index=None):
"""Return page widget"""
if index is None:
widget = self.pages_widget.currentWidget()
else:
widget = self.pages_widget.widget(index)
return widget.widget()
[docs]
def accept(self):
"""Reimplement Qt method"""
for configpage in self.pages:
if not configpage.is_valid:
continue
configpage.apply_changes()
QDialog.accept(self)
[docs]
def apply_clicked(self):
# Apply button was clicked
configpage = self.get_page()
if configpage.is_valid:
configpage.apply_changes()
self.check_changes(configpage)
[docs]
def add_page(self, widget):
"""Add a new page to the preferences dialog
Parameters
----------
widget: ConfigPage
The page to add"""
widget.validChanged.connect(self.bt_apply.setEnabled)
widget.validChanged.connect(
self.bbox.button(QDialogButtonBox.Ok).setEnabled
)
scrollarea = QScrollArea(self)
scrollarea.setWidgetResizable(True)
scrollarea.setWidget(widget)
self.pages_widget.addWidget(scrollarea)
item = QListWidgetItem(self.contents_widget)
try:
item.setIcon(widget.icon)
except TypeError:
pass
item.setText(widget.title)
item.setFlags(Qt.ItemIsSelectable | Qt.ItemIsEnabled)
item.setSizeHint(QtCore.QSize(0, 25))
widget.propose_changes.connect(self.check_changes)
[docs]
def check_changes(self, configpage):
"""Enable the apply button if there are changes to the settings"""
if configpage != self.get_page():
return
self.bt_apply.setEnabled(
not configpage.auto_updates
and configpage.is_valid
and configpage.changed
)
[docs]
def load_plugin_pages(self):
"""Load the rcParams for the plugins in separate pages"""
validators = psy_rcParams.validate
descriptions = psy_rcParams.descriptions
for ep in psy_rcParams._load_plugin_entrypoints():
plugin = ep.load()
rc = getattr(plugin, "rcParams", None)
if rc is None:
rc = RcParams()
w = RcParamsWidget(parent=self)
w.title = "rcParams of " + ep.module
w.default_path = PsyRcParamsWidget.default_path
w.initialize(
rcParams=rc, validators=validators, descriptions=descriptions
)
# use the full rcParams after initialization
w.rc = psy_rcParams
self.add_page(w)