from collections.abc import Iterable
from pathlib import Path
from typing import Union, Any, TYPE_CHECKING, Sequence
from qtpy import QtWidgets, QtCore
from qtpy.QtCore import QMimeData, Qt, QModelIndex
from qtpy.QtWidgets import QDialogButtonBox, QDialog
from pymodaq_gui.managers.settings.subentries import SubEntryHandlerFactory, SubEntryHandlerTypes, \
SubEntry
from pymodaq_utils.math_utils import get_si_prefix
from pymodaq_utils.utils import capitalize, read_binary_and_deserialize
from pymodaq_utils.logger import set_logger, get_module_name
from pymodaq_utils.enums import StrEnum
from pymodaq_utils.array_manipulation import are_elements_contiguous
from pymodaq_gui.parameter.utils import ParameterWithPath
from pymodaq_gui.parameter.ioxml import VALID_FOR_CONFIGURATION
from pymodaq_gui.qvariant import QVariant
from pymodaq_gui.parameter import ParameterTree, Parameter
from pymodaq_gui.utils.widgets.table import TableModel
from pymodaq_gui import utils as gutils
from serializall import SerializableFactory, SerializableBase
import copy
logger = set_logger(get_module_name(__file__))
ser_factory = SerializableFactory()
special_entry_factory = SubEntryHandlerFactory()
if TYPE_CHECKING:
from pymodaq_gui.managers.settings.settings_manager import SettingsManager
[docs]
class EntryActions(StrEnum):
ADD = 'add_entry'
REMOVE = 'remove_entry'
UP = 'move_entry_up'
DOWN = 'move_entry_down'
[docs]
class ParameterDelegate(QtWidgets.QStyledItemDelegate):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
[docs]
def createEditor(self, parent, option, index: QModelIndex):
parameter: Parameter = index.model().get_data(index.row()).setting.parameter
widget: QtWidgets.QWidget = parameter.itemClass(parameter, depth=0).makeWidget()
widget.setParent(parent)
widget.setAutoFillBackground(True)
# Set size policy to fill the cell
widget.setSizePolicy(
QtWidgets.QSizePolicy.Policy.Expanding,
QtWidgets.QSizePolicy.Policy.Expanding,
)
# Force widget to fill cell height
available_height = option.rect.height()
widget.setMinimumHeight(available_height)
widget.setMaximumHeight(available_height)
# Remove layout margins if present
if widget.layout() is not None:
widget.layout().setContentsMargins(0, 0, 0, 0)
widget.layout().setSpacing(0)
# Connect signals for auto-commit on value change or focus loss
self._connect_editor_signals(widget)
return widget
def _connect_editor_signals(self, widget):
"""Connect widget signals to auto-commit data changes"""
# Try common value changed signals
# if hasattr(widget, 'toggled'):
# widget.toggled.connect(lambda: self.commitData.emit(widget))
if hasattr(widget, 'currentIndexChanged'): # For comboboxes
widget.currentIndexChanged.connect(lambda: self.value_changed(widget))
# elif hasattr(widget, 'editingFinished'):
# widget.editingFinished.connect(lambda: self.value_changed(widget))
# elif hasattr(widget, 'stateChanged'): # For checkboxes
# widget.stateChanged.connect(lambda: self.commitData.emit(widget))
# elif hasattr(widget, 'checkStateChanged'): # For checkboxes
# widget.checkStateChanged.connect(lambda: self.commitData.emit(widget))
#
# Install event filter to catch focus loss
widget.installEventFilter(self)
pass
[docs]
def value_changed(self, widget: QtWidgets.QWidget):
QtWidgets.QApplication.processEvents()
self.commitData.emit(widget)
[docs]
def setEditorData(self, editor, index: QModelIndex):
try:
editor.setValue(index.data())
except:
super().setEditorData(editor, index)
[docs]
def setModelData(self, editor, model, index: QModelIndex):
model.setData(index, copy.copy(editor.value()), Qt.ItemDataRole.EditRole)
[docs]
def updateEditorGeometry(self, editor, option, index):
"""Ensure editor fills the cell completely"""
rect = QtCore.QRect(option.rect)
available_height = rect.height()
editor.setMinimumHeight(available_height)
editor.setMaximumHeight(available_height)
editor.setGeometry(rect)
[docs]
def sizeHint(self, option, index):
"""Provide size hint for cells with widgets"""
if index.column() == 2:
hint = super().sizeHint(option, index)
hint.setHeight(max(hint.height(), 40))
return hint
return super().sizeHint(option, index)
mock_list = ['elt1', 'elt2', 'elt3']
mock_entry = SubEntry('settings',
'Photodiode',
ParameterWithPath(
parameter=Parameter.create(
title='mytitle', name='myname',
type='list', value=mock_list[0],
limits=mock_list)))
[docs]
class SettingsManagerModel(TableModel):
update_delegate = QtCore.Signal()
def __init__(self, data: list[SubEntry]=None,
header=('Type', 'Module', 'Title', 'Value'),
save_path: Path = None
):
self._data: list[SubEntry] | None = None
self.save_path = save_path
if data is None:
data = []
super().__init__(data, header, editable=[False, False, False, True])
pass
[docs]
def columnCount(self, parent):
return 4
[docs]
def mimeTypes(self):
types = super().mimeTypes()
types.append('pymodaq/parameter_with_path')
types.append('pymodaq/settings_entry')
return types
[docs]
def mimeData(self, items):
data = QMimeData()
rows = list(set([item.row() for item in items]))
if are_elements_contiguous(rows):
entries = [self._data[raw] for raw in rows]
data.setData('pymodaq/settings_entry', ser_factory.get_apply_serializer(entries))
return data
[docs]
def data(self, index: QModelIndex, role: Qt.ItemDataRole):
if index.isValid():
if role == Qt.ItemDataRole.DisplayRole or role == Qt.ItemDataRole.EditRole:
entry: SubEntry = self._data[index.row()]
if index.column() == 0:
dat = capitalize(entry.entry_type)
elif index.column() == 1:
dat = entry.module_name
elif index.column() == 2:
dat = entry.setting.parameter.title()
elif index.column() == 3:
suffix = ''
if entry.setting.parameter.opts.get('suffix', None) is not None:
suffix = f' {entry.setting.parameter.opts.get("suffix")}'
val, prefix = get_si_prefix(entry.setting.parameter.value())
dat = f"{val} {prefix}{suffix}"
else:
dat = ''
return dat
elif role == Qt.ItemDataRole.CheckStateRole and index.column() == 0 and self._show_checkbox:
if self._checked[index.row()]:
return Qt.CheckState.Checked
else:
return Qt.CheckState.Unchecked
elif role == Qt.ItemDataRole.ToolTipRole:
entry: SubEntry = self._data[index.row()]
return repr(entry)
return QVariant()
[docs]
def dropMimeData(self, data: QMimeData, action: Qt.DropAction, row: int, column: int, parent: QModelIndex):
if row == -1:
row = self.rowCount(parent)
if data.hasFormat('pymodaq/settings_entry'):
entries: list[SubEntry] = (
ser_factory.get_apply_deserializer(
data.data('pymodaq/settings_entry').data()))
else:
entries = [mock_entry]
if action == QtCore.Qt.DropAction.MoveAction:
pass
# this is strange if I move things around using drag/drop
# sometimes the MoveRows is called immediately without calling drop???
# the code below is therefore not needed (but is still called in case of a drop!!)
# for ind, entry in enumerate(entries):
# self.data_tmp = entry
# start_row = self._data.index(entry)
# #self.moveRows(start_row, len(entries))
# self.moveRow(parent, start_row, parent, row)
elif action == QtCore.Qt.DropAction.CopyAction: #but only one item in the list in Copy mode
self.data_tmp = self.split_entry(entries[0]) # in case the entry has children parameters
for entry in self.data_tmp: #make sure there is no duplicate
if entry in self._data:
self.data_tmp.remove(entry)
self.insertRows(row, len(self.data_tmp), parent)
self.update_delegate.emit()
return True
[docs]
def setData(self, index, value, role):
if index.isValid():
if role == Qt.ItemDataRole.EditRole:
if self.validate_data(index.row(), index.column(), value):
self._data[index.row()].setting.parameter.setValue(value)
self.dataChanged.emit(index, index, [role])
return True
else:
return False
elif role == Qt.ItemDataRole.CheckStateRole:
self._checked[index.row()] = True if value == Qt.CheckState.Checked else False
self.dataChanged.emit(index, index, [role])
return True
return False
[docs]
def split_entry(self, entry: SubEntry,
entries: list[SubEntry] = None) -> list[SubEntry]:
""" Split A settingsEntry into multiple entries if its underlying parameter has children"""
if entries is None:
entries = []
if not entry.setting.parameter.hasChildren():
if entry.setting.parameter.opts.get(VALID_FOR_CONFIGURATION, True): # only add the ones specifying they are configurable
entries.append(entry)
else:
for child in entry.setting.parameter.children():
if child.opts.get(VALID_FOR_CONFIGURATION, True) : # only add the ones specifying they are configurable
pwp = ParameterWithPath(parameter=child, path=entry.setting.path + [child.name()])
config_entry = SubEntry(entry.entry_type, entry.module_name, entry.module_type, pwp)
self.split_entry(config_entry, entries)
return entries
[docs]
def moveRow(self, sourceParent: QModelIndex, sourceRow: int,
destinationParent: QModelIndex, destinationChild: int) -> bool:
if (destinationChild > self.rowCount() or
destinationChild < 0):
return False
self.beginMoveRows(sourceParent, sourceRow, sourceRow,
destinationParent, destinationChild)
entry_to_be_moved = self._data.pop(sourceRow)
self._data.insert(destinationChild if destinationChild < sourceRow else destinationChild -1,
entry_to_be_moved)
self.endMoveRows()
return True
[docs]
def moveRows(self, sourceParent: QModelIndex, sourceRow: int, count: int,
destinationParent: QModelIndex, destinationChild: int) -> bool:
if count == 1:
self.moveRow(sourceParent, sourceRow, destinationParent, destinationChild)
else:
super().moveRows(sourceParent, sourceRow, count,
destinationParent, destinationChild)
[docs]
def insertRows(self, row, count, parent):
self.beginInsertRows(QtCore.QModelIndex(), row, row + count - 1)
for ind in range(count):
self._data.insert(row + ind, self.data_tmp[ind] if
(hasattr(self.data_tmp, '__len__') and len(self.data_tmp) == count) else self.data_tmp)
self._checked.insert(row + ind, False)
self.endInsertRows()
return True
[docs]
def clear(self):
while self.rowCount() > 0:
self.remove_row(0)
[docs]
def add_data(self, row, data: SubEntry):
if data is not None:
if data in self._data:
return
self.insert_data(row, data)
self.update_delegate.emit()
[docs]
def remove_data(self, row):
self.remove_row(row)
self.update_delegate.emit()
[docs]
def load(self, fname: Union[str, Path, Sequence[bytes]] = None):
if fname is None:
fname = gutils.select_file(start_path=self.save_path, save=False, ext='*')
if fname is not None and fname != '':
#first remove all the table rows
while self.rowCount(self.index(-1, -1)) > 0:
self.remove_row(0)
if isinstance(fname, (Path, str)):
# populate it from full file containning binary list od Subentry
self.save_path = fname.parent
data = read_binary_and_deserialize(Path(fname))
elif isinstance(fname, Iterable):
#populate the table from list of bytes encoding SubEntries
data = fname
else:
data = []
for row in data:
self.insert_data(self.rowCount(self.index(-1, -1)), row)
self.update_delegate.emit()
[docs]
def save(self, fname: str = None, mode='wb'):
if fname is None:
fname = gutils.select_file(start_path=self.save_path, save=True, ext='config',
force_save_extension=True)
if isinstance(fname, Path):
self.save_path = fname.parent
with open(fname, mode) as file:
file.writelines([ser_factory.get_apply_serializer(entry) for entry in self._data])
[docs]
class SettingsManagerTableView(QtWidgets.QTableView):
"""
"""
valueChanged = QtCore.Signal(list)
add_data_signal = QtCore.Signal(str)
remove_row_signal = QtCore.Signal(int)
load_data_signal = QtCore.Signal()
save_data_signal = QtCore.Signal()
def __init__(self, menu=False,
entry_type: str='settings'):
super().__init__()
self.entry_type = entry_type.capitalize()
self.setmenu(menu)
#self.doubleClicked.connect(self.edit_row)
self.horizontalHeader().setSectionResizeMode(QtWidgets.QHeaderView.ResizeMode.ResizeToContents)
[docs]
def edit_row(self):
index = self.currentIndex()
index.model().edit_data(index)
[docs]
def clear(self):
self.model().clear()
[docs]
def add(self, special_entry: str):
self.add_data_signal.emit(special_entry)
[docs]
def remove(self):
""" Remove selected rows, starting from the last one (to not mess with indexing)"""
rows = list(set([index.row() for index in self.selectedIndexes()]))
rows.sort(key=lambda row: -row)
for row in rows:
self.remove_row_signal.emit(row)
[docs]
def data_has_changed(self, topleft, bottomright, roles):
self.valueChanged.emit([topleft, bottomright, roles])
[docs]
def get_table_value(self):
"""
"""
return self.model()
[docs]
def set_table_value(self, data_model):
"""
"""
try:
self.setModel(data_model)
self.model().dataChanged.connect(self.data_has_changed)
except Exception as e:
pass
[docs]
class SettingsManagerParameterTree(ParameterTree):
def __init__(self, manager:'SettingsManager', *args,
handler_id='Settings',
**kwargs):
super().__init__(*args, **kwargs)
self.manager = manager
self.handler_id = handler_id
[docs]
def mimeTypes(self):
types = super().mimeTypes()
types.append('pymodaq/parameter_with_path')
types.append('pymodaq/settings_entry')
return types
[docs]
def mimeData(self, items):
data = QMimeData()
param_with_path = ParameterWithPath(items[0].param)
try:
module = self.manager.get_module_from_param(param_with_path)
except KeyError:
module = 'None'
if module is not None:
entry = SubEntry(self.handler_id,
module,
param_with_path)
data.setData('pymodaq/settings_entry',
ser_factory.get_apply_serializer([entry]))
return data