"""
Framework to simplify creating Qt tables based on parameters.ParamListParam
(PLP). A PLP is a type of list param where each element is itself a compound
param. These can be naturally represented as tables where each item in the list
is a row in the table, and individual subparams (fields) of the item are shown
as cells in the row. See scripts/examples/models/plptable_gui.py for an example.
To create a plptable, you first need a model object with a ParamListParam that
contains the source data. Each item in the list corresponds to a row in the
table, and the cells in that row are based on the fields in that item.
For example::
class Contact(parameters.CompoundParam):
name = parameters.StringParam()
phone_number = parameters.StringParam()
email = parameters.StringParam()
class Model(parameters.CompoundParam):
contacts = parameters.ParamListParam(item_class=Contact)
In the simplest case, a PLPTableWidget can be instantiated with autospec=True,
resulting in a very basic table with one column for each field in the PLP
item class::
model = Model()
table = plptable.PLPTableWidget(plp=model.contacts, autospec=True)
Typically, it is desirable to customize the column contents and how the data in
the list item is used to populate the columns. This is accomplished by sub-
classing the TableSpec class to create a table specification. A table spec
defines the columns in a table and provides the data methods used to populate
the cells in each column.
In the example above, we might want to split the name into separate first and
last name columns and turn the email into a hyperlink. This could be done with
the following table spec::
class ContactTableSpec(plptable.TableSpec):
@plptable.FieldColumn(Contact.name)
def first_name(self, field):
return field.split()[0]
@plptable.FieldColumn(Contact.name)
def last_name(self, field):
return field.split()[-1]
phone_number = plptable.FieldColumn(Contact.phone_number)
@plptable.FieldColumn(Contact.email)
def email(self, field):
return f'<a href = "mailto: {field}">{field}</a>'
Notice that there are two ways to declare a column - either with a class
attribute or a decorated data method.
Once a spec is defined, it can be applied to a `PLPTableWidget` using the
`setSpec` method.
For more information, see `TableSpec` as well as the various column classes.
"""
import collections
import copy
import inspect
import types
from enum import Enum
from schrodinger.models import mappers
from schrodinger.models import parameters
from schrodinger.Qt import QtCore
from schrodinger.Qt import QtWidgets
from schrodinger.Qt.QtCore import Qt
from schrodinger.ui.qt import basewidgets
from schrodinger.ui.qt import table
from schrodinger.ui.qt import table_helper
# TODO: Add a public method to assign a delegate for a column
# TODO: Maybe add a role thats queried for column width
"""
The `ROW_OBJECT_ROLE` will return the row object (i.e. the param) for a given index.
"""
ROW_OBJECT_ROLE = table_helper.ROW_OBJECT_ROLE
def _find_ordered_attrs(obj):
"""
Returns a list of all attributes accessible from an object, in MRO order.
:param obj: the object to traverse
:return: list of all attribute
"""
namespaces = [list(obj.__dict__.keys())]
mro = obj.__class__.mro()
namespaces.extend([cls.__dict__.keys() for cls in mro])
attrs = []
names = set()
for namespace in namespaces:
for name in namespace:
if name in names:
continue
names.add(name)
attr = getattr(obj, name)
attrs.append(attr)
return attrs
def _find_param(param, params):
"""
Utility function for finding the index of a param in a list by identity.
:param param: the param to search for
:param params: the list of params
:return: index of the param
"""
for i, p in enumerate(params):
if p is param:
return i
raise ValueError(f'{param} is not in {params}')
_DataMethodType = Enum('_DataMethodType',
'GetData SetData HeaderData ColumnCount')
_GET_DATA = _DataMethodType.GetData
_SET_DATA = _DataMethodType.SetData
_HEADER_DATA = _DataMethodType.HeaderData
_COLUMN_COUNT = _DataMethodType.ColumnCount
#===============================================================================
# Column classes
#===============================================================================
class _BaseColumnMeta(type):
"""
To avoid incorrect usage of this decorator, check the value of the first
positional argument for initializing the class.
"""
def __call__(cls, *args, **kwargs):
if not kwargs and len(args) == 1 and inspect.isfunction(args[0]):
class_name = cls.__name__
msg = (f'Unrecognized function {args[0]} received as initialization'
f' parameter. If you are using {class_name} as a'
' decorator, rememeber to instantiate it, e.g.'
f'\n @{class_name}()'
'\n def foo():'
'\n ...'
'\nRather than'
f'\n @{class_name}'
'\n def foo():'
'\n ...')
raise RuntimeError(msg)
return super().__call__(*args, **kwargs)
class _BaseColumn(metaclass=_BaseColumnMeta):
"""
Abstract column that defines shared functionality for all columns.
"""
def __init__(self,
title=None,
editable=False,
tooltip=None,
sample_data=''):
"""
:param title: The title of the column. This string will be shown
in the header cell of the column. If `None`, the title will
default to the variable name the column is set to.
:type title: str or None
:param editable: Whether the cells in the column are editable. If
`editable` is set to `True`, then double clicking a cell
in the column will allow the user to input a string.
:type editable: bool
:param tooltip: The string to populate the tooltip with.
:type tooltip: str or None
"""
self.title = title
self.editable = editable
self.tooltip = tooltip
self.sample_data = sample_data
self.data_methods = {}
self._default_data_methods = {}
self._is_abstract = True
self.column_name = None
def _titleData(*args):
return title
def _tooltipData(*args):
return tooltip
self._addDefaultDataMethod(_titleData,
role=Qt.DisplayRole,
data_method_type=_HEADER_DATA)
self._addDefaultDataMethod(_tooltipData,
role=Qt.ToolTipRole,
data_method_type=_HEADER_DATA)
def _findDataMethods(self, table_spec):
"""
Searches through a table spec to find all data methods that apply to the
abstract column (i.e. the class attribute column).
:param table_spec: the table spec instance to search for data methods
:type table_spec: TableSpec
"""
data_methods = {}
# reverse order so that the subclass methods will take precedence
for attr in reversed(_find_ordered_attrs(table_spec)):
if not callable(attr):
continue
try:
if self not in attr.columnsToRoles:
continue
for role in attr.columnsToRoles[self]:
data_methods[attr.type, role] = attr
except AttributeError:
pass
return data_methods
def __set_name__(self, _, name):
"""
Save the variable name this column is set as.
:param name: The variable name
:type name: str
"""
if self.title is None:
def _titleData(*args):
return self.column_name
self._addDefaultDataMethod(_titleData,
data_method_type=_HEADER_DATA)
self.column_name = name
def generateColumn(self, table_spec):
"""
Creates a copy of this column to be used as instance members of
TableSpec instances. This allows separate instances and subclasses of
a TableSpec to modify columns without altering the original class
attribute, which is considered an "abstract" column.
:param table_spec: the table spec instance that will own the generated
column instance.
:type table_spec: TableSpec
"""
new_col = copy.copy(self)
new_col._is_abstract = False
new_col.data_methods = {}
for key, rdm in self._default_data_methods.items():
new_col.data_methods[key] = types.MethodType(rdm, table_spec)
data_methods = self._findDataMethods(table_spec)
if ((_GET_DATA, Qt.DisplayRole) in data_methods and
(_GET_DATA, Qt.EditRole) not in data_methods):
data_methods[(_GET_DATA, Qt.EditRole)] = \
data_methods[(_GET_DATA, Qt.DisplayRole)]
new_col.data_methods.update(data_methods)
if new_col.title is None:
new_col.title = new_col.column_name
return new_col
def _wrapDataMethod(self, data_func, roles, data_method_type=_GET_DATA):
if not hasattr(data_func, 'columnsToRoles'):
data_func.columnsToRoles = collections.defaultdict(set)
data_func.columnsToRoles[self].update(roles)
data_func.type = data_method_type
return data_func
def data_method(self, *roles, role=Qt.DisplayRole):
"""
A decorator that marks a method to be used as a data method for the
column. The method will be used as the data method whenever data for
`role` or `roles` is requested.
:param roles: The roles that this data method should be used for. All
positional arguments will be considered a part of `roles` and should
be hashable.
:param role: Keyword-only argument. The role to assign to the data
method. Usually enums are used for roles but theoretically any
hashable object can be used.
:type role: Hashable
"""
roles = list(roles)
if not roles:
roles.append(role)
if ROW_OBJECT_ROLE in roles:
raise ValueError("Overriding the ROW_OBJECT_ROLE "
"data method is not allowed.")
def wrapper(data_func):
return self._wrapDataMethod(data_func, roles)
return wrapper
def headerData_method(self, *roles, role=Qt.DisplayRole):
"""
A decorator that marks a method to be used as a header data method for
the column. The method will be used as the data method whenever data
for `role` or `roles` is requested for the header row. Header
data methods are passed the entire PLP.
:param roles: The roles that this data method should be used for. All
positional arguments will be considered a part of `roles` and should
be hashable.
:param role: Keyword-only argument. The role to assign to the data
method. Usually enums are used for roles but theoretically any
hashable object can be used.
:type role: Hashable
"""
roles = list(roles)
if not roles:
roles.append(role)
def wrapper(data_func):
return self._wrapDataMethod(data_func, roles, _HEADER_DATA)
return wrapper
def setData_method(self, role=Qt.EditRole):
"""
A decorator for specifying a setData method for the column.
"""
def wrapper(data_func):
return self._wrapDataMethod(data_func, [role], _SET_DATA)
return wrapper
def _addDefaultDataMethod(self,
method,
role=Qt.DisplayRole,
data_method_type=_GET_DATA):
"""
Register a data method that isn't actually on the table spec itself.
These data methods have the lowest priority and are generally used for
default data methods. They will be overridden if any methods are
decorated with `*data_method` and assigned the same role. Unlike
`data_method`, this method is not a decorator and should be called
directly with the desired data method.
:param method: A method to assign as a data method.
:type method: callable
:param role: The role to assign to the data method.
:type role: Hashable
"""
self._default_data_methods[data_method_type, role] = method
def __call__(self, data_func):
"""
For convenience, this allows the column to be initialized with a
data method all at once. For example::
class SimpleSpec(TableSpec):
@ParamColumn()
def my_column(self, param):
return param
In this example, the column will take on the name and title `my_column`
and will have a Qt.DisplayRole data method that just returns the param.
The above example is equivalent to::
class SimpleSpec(TableSpec):
my_column = ParamColumn()
@my_column.data_method(role=Qt.DisplayRole)
def _my_col_data_method(self, param):
return param
:param data_func: A function to assign as the default data method
:type data_func: callable
"""
# This is not really what default data method is intended for, but it
# works. We should consider changing this in PANEL-12627
self._addDefaultDataMethod(data_func)
# By default, the displayed data is the starting point for editing
self._addDefaultDataMethod(data_func, role=Qt.EditRole)
return self
def data(self, all_rows, this_row, role=Qt.DisplayRole):
"""
Get the corresponding data for the column for the specified role.
"""
method = self.data_methods.get((_GET_DATA, role))
if method is None:
return None
data_method_args = self._generateDataMethodArgs(all_rows, this_row)
return method(*data_method_args)
def setData(self, all_rows, this_row, data, role=Qt.EditRole):
method = self.data_methods.get((_SET_DATA, role))
if method is None:
raise NotImplementedError('Column has no setData method.')
args = self._generateDataMethodArgs(all_rows, this_row) + (data,)
return method(*args)
def headerData(self, all_rows, role=Qt.DisplayRole):
method = self.data_methods.get((_HEADER_DATA, role))
if method is None:
return None
return self.data_methods[_HEADER_DATA, role](all_rows)
def _generateDataMethodArgs(self, all_rows, this_row):
"""
Generate the arguments to be passed into the columns data methods.
Data methods can take different arguments based on the type of column.
See the children classes of `_BaseColumn` for examples.
:param all_rows: A list of the rows that make up the table.
:type all_rows: list(parameters.CompoundParam)
:param this_row: The row we're generating data for.
:type this_row: parameters.CompoundParam
"""
raise NotImplementedError
def getNumColumns(self, all_rows):
"""
Get the number of columns this column has. Generally this is just one
but some children classes define more than one column.
:param all_rows: A list of the rows that make up the table.
:type all_rows: list(parameters.CompoundParam)
:return: The number of columns.
:rtype: int
"""
return 1
def __str__(self):
if self.column_name:
s = f'{self.column_name} = '
else:
s = ''
s += f'{self.__class__.__name__}(title="{self.title}")'
return s
[docs]class FieldColumn(_BaseColumn):
"""
A FieldColumn is a column in which each cell receives the data from a single
subparam (i.e. field) of one item in the PLP.::
@FieldColumn(ItemClass.subparam)
def my_data_method(self, field)
field: the value of the param field associated with this row
"""
[docs] def __init__(self, field, **kwargs):
"""
:param field: An abstract param representing the field this column
represents or uses.
:type field: parameters.Param
"""
super().__init__(**kwargs)
self._abstract_field = field
def _get_param(self, field):
"""
A default data method that just returns the corresponding
value of a param.
"""
return field
self._addDefaultDataMethod(_get_param)
self._addDefaultDataMethod(_get_param, role=Qt.EditRole)
def _set_param(self, field, value):
return value
self._addDefaultDataMethod(_set_param,
role=Qt.EditRole,
data_method_type=_SET_DATA)
def _generateDataMethodArgs(self, all_rows, this_row):
return (self._abstract_field.getParamValue(this_row),)
[docs] def setData(self, all_rows, this_row, data, role=Qt.EditRole):
field = super().setData(all_rows, this_row, data, role)
self._abstract_field.setParamValue(this_row, field)
[docs]class ParamColumn(_BaseColumn):
"""
A ParamColumn is a column in which each cell receives one entire item from
the PLP. It's up to the data method to decide how to convert the item into
something that can be used by the cell. ::
@ParamColumn()
def my_data_method(self, this_row)
this_row: the model object for this row.
"""
def _generateDataMethodArgs(self, all_rows, this_row):
return (this_row,)
[docs]class PLPColumn(_BaseColumn):
"""
A PLPColumn is a column in which each cell receives the entire PLP (i.e. the
entire table's data). This allows each cell's contents to account for data
in other rows in the table. It's up to the data method to decide how to use
the entire table data in each individual cell. ::
@PLPColumn()
def my_data_method(self, all_rows, this_row):
all_rows: a list where each element is the model object for one row
this_row: the model object for this row
"""
def _generateDataMethodArgs(self, all_rows, this_row):
return (all_rows, this_row)
[docs]class ColumnSeries(PLPColumn):
_column_count_defined = False
"""
A ColumnSeries is a dynamically generated series of columns for which each
cell receives the entire plp, the item corresponding to cell's row, and an
index indicating which column in the ColumnSeries this cell belongs to.
The number of columns is determined by column_count decorated method.
def my_data_method(self, col_idx, all_rows, this_row)
col_idx: the relative index of the column with the column series
all_rows: a list where each element is the model object for one row
this_row: the model object for this row
Example::
class FamilyTableSpec(TableSpec):
siblings = ColumnSeries()
@siblings.column_count
def numColumns(self, all_rows):
return max(len(row.siblings) for row in all_rows)
@siblings.data_method()
def getSibling(self, col_idx, all_rows, this_row):
try:
return this_row.siblings[col_idx]
except IndexError:
return ''
Or, equivalently::
class FamilyTableSpec(TableSpec):
@ColumnSeries()
def siblings(self, all_rows):
return max(len(row.siblings) for row in all_rows)
@siblings.data_method()
def getSibling(self, col_idx, all_rows, this_row):
try:
return this_row.siblings[col_idx]
except IndexError:
return ''
"""
[docs] def getNumColumns(self, all_rows):
"""
Call the `column_count` method to see how many columns make up the
series.
:param all_rows: A list of the rows that make up the table.
:type all_rows: list(parameters.CompoundParam)
:return: The number of columns in the series
:rtype: int
"""
method = self.data_methods.get((_COLUMN_COUNT, Qt.DisplayRole))
if method is None:
return 0
return method(all_rows)
[docs] def column_count(self, *args):
"""
Decorator to designate a function to calculate the number of columns
in the series.
"""
if args:
raise RuntimeError('column_count is a decorator factory and must '
'be called to return the actual decorator. '
'Add the parentheses to @column_count().')
if self._column_count_defined:
raise RuntimeError(f'{self} already has a column count method.')
roles = [Qt.DisplayRole]
def wrapper(data_func):
return self._wrapDataMethod(data_func,
roles,
data_method_type=_COLUMN_COUNT)
self._column_count_defined = True
return wrapper
def __call__(self, len_func):
"""
A convenience method that allows a column series to be initialized and
then used as a decorator to designate a `column_count` method.
:param len_func: Function to calculate the number of columns. Should
take in one argument: the `ParamListParam` representing the table.
:type len_func: callable
"""
self._addDefaultDataMethod(len_func, data_method_type=_COLUMN_COUNT)
self._column_count_defined = True
return self
[docs] def data(self, col_idx, all_rows, this_row, role=Qt.DisplayRole):
# TODO: Error if col_idx > self.getNumColumns()
method = self.data_methods.get((_GET_DATA, role))
if method is None:
return None
return method(col_idx, all_rows, this_row)
[docs] def setData(self, all_rows, this_row, data, role=Qt.EditRole):
raise NotImplementedError('Editing not supported in column series')
[docs] def headerData(self, col_idx, all_rows, role=Qt.DisplayRole):
# TODO: Error if col_idx > self.getNumColumns()
method = self.data_methods.get((_HEADER_DATA, role))
if method is None:
return None
return method(col_idx, all_rows)
#===============================================================================
# Table spec class
#===============================================================================
[docs]class TableSpec(parameters.CompoundParam):
"""
A class that represents the specification of a `PLPTable`. The spec's
role is to specify what columns to display, in what order, and with
what data.
To create a table spec, subclass TableSpec and add columns and data methods
to define the behavior of the table.
"""
columns = parameters.ListParam()
[docs] def __init__(self):
super().__init__()
self._length = None
self.columns = self._findColumns()
def _findColumns(self):
"""
Searches through the entire class hierarchy to find columns, which are
copied into instance columns usince column.generateColumn. Columns in
child classes will override columns of the same name in parent classes.
"""
cols = {}
for cls in reversed(self.__class__.mro()):
for attr_name, attr in cls.__dict__.items():
if isinstance(attr, _BaseColumn):
cols[attr_name] = attr.generateColumn(self)
return list(cols.values())
[docs] def data(self, col_idx, all_rows, this_row, role):
#TODO: Add input validation for col_idx
col = self.getColumn(col_idx, all_rows)
if isinstance(col, ColumnSeries):
#FIXME: breaks with multiple column series
offset = self.columns.index(col)
return col.data(col_idx - offset, all_rows, this_row, role)
else:
return col.data(all_rows, this_row, role)
[docs] def setData(self, col_idx, all_rows, this_row, value, role=Qt.EditRole):
#FIXME: doesn't work for column series or any column after a series
col = self.getColumn(col_idx, all_rows)
return col.setData(all_rows, this_row, value, role)
[docs] def getColumn(self, col_idx, all_rows):
if col_idx >= self.getNumColumns(all_rows):
raise IndexError(f'Column index {col_idx} out of range')
idx = -1
for col in self.columns:
if isinstance(col, ColumnSeries):
idx += col.getNumColumns(all_rows)
else:
idx += 1
if idx >= col_idx:
return col
[docs] def getNumColumns(self, all_rows):
length = 0
for col in self.columns:
length += col.getNumColumns(all_rows)
return length
[docs] def getColumnIndex(self, column, all_rows):
"""
Note that for `column` of type `ColumnSeries` we return the index of
the 0th column in the series.
:param column: Column to return the index for.
:type column: Subclass instance of _BaseColumn
:param all_rows: All rows of the plp table.
:type all_rows: parameters.ParamListParam
:return: Column index.
:rtype: int
:raises KeyError: If `column` not found in the table spec.
"""
col_idx = 0
for col in self.columns:
if col.column_name == column.column_name:
return col_idx
# We increase the col_idx by the total number of columns in the
# column type to account for ColumnSeries.
col_idx += col.getNumColumns(all_rows)
raise KeyError(f'{column.column_name} not found in table spec.')
def __str__(self):
s_list = ['TableSpec']
for col in self.columns:
s_list.append(str(col))
return '\n '.join(s_list)
#===============================================================================
# The main PLP table widget
#===============================================================================
#===============================================================================
# PLP internal implementaion classes
#===============================================================================
class _PLPTableModel(QtCore.QAbstractTableModel):
"""
Base class for dynamically generated table model to integrate with
schrodinger.models.parameters.ParamListParam.
"""
# Use an empty tuple to represent the default empty PLP
EMPTY_PLP = tuple()
INVALID_INDEX = QtCore.QModelIndex()
def __init__(self, *args, spec=None, **kwargs):
super().__init__(*args, **kwargs)
self._plp = self.EMPTY_PLP
self._spec = None
self._cached_col_count = None
self._has_variable_columns = False
self.setSpec(spec)
@property
def spec(self):
return self._spec
@spec.setter
def spec(self, _):
raise AttributeError('spec can only be set using the setSpec method')
def setSpec(self, new_spec):
"""
Set a new spec for the table model. This will disconnect signals from
the old spec and connect signals to the new spec.
:param new_spec: The desired new spec
:type new_spec: TableSpec
"""
if self._spec is not None:
self._spec.columnsChanged.disconnect(self.onColumnsChanged)
if new_spec is None:
new_spec = TableSpec()
# TODO: present spec change using columnsAboutToBeInserted/Removed and
# columnsInserted/Removed (PANEL-19048)
self.beginResetModelAndClearCache()
self._spec = new_spec
self._spec.columnsChanged.connect(self.onColumnsChanged)
self._checkIfVariableColumns()
self.endResetModel()
def beginResetModelAndClearCache(self):
self.beginResetModel()
self._clearCachedColCount()
def onColumnsChanged(self):
# TODO: present column changes using columnsAboutToBeInserted/Removed
# and columnsInserted/Removed (PANEL-19048)
self.beginResetModelAndClearCache()
self._checkIfVariableColumns()
self.endResetModel()
def _checkIfVariableColumns(self):
"""
Determine whether the spec contains any `ColumnSeries` instances, since
that means that the number of columns can change in response to *any*
change in the model.
"""
self._has_variable_columns = any(
isinstance(col, ColumnSeries) for col in self._spec.columns)
def setPLP(self, plp):
"""
Set the supplied PLP as the new model for the table.
:param plp: The PLP representing the table data.
:type plp: list(parameters.CompoundParam)
"""
self.beginResetModelAndClearCache()
if self._plp is not self.EMPTY_PLP:
for signal, slot in self._getSignalsAndSlots(self._plp):
signal.disconnect(slot)
self._plp = plp
for signal, slot in self._getSignalsAndSlots(self._plp):
signal.connect(slot)
self.endResetModel()
def _getSignalsAndSlots(self, plp):
return (
(plp.itemsAboutToBeInserted, self._onItemsAboutToBeInserted),
(plp.itemsInserted, self._onItemsInserted),
(plp.itemsAboutToBeRemoved, self._onItemsAboutToBeRemoved),
(plp.itemsRemoved, self._onItemsRemoved),
(plp.itemsAtIndicesReplaced, self._onItemsAtIndicesReplaced),
(plp.itemsAboutToBeReset, self.beginResetModelAndClearCache),
(plp.itemsReset, self._onItemsReset),
(plp.itemChanged, self._onItemChanged),
)
def _clearCachedColCount(self):
self._cached_col_count = None
def _updateVariableColumnsIfNeeded(self):
"""
Check whether the number of columns has changed as a result of a
`ColumnSeries` responding to a change in the model. If the number of
columns have changed, add or remove columns from the right side of the
table. If the number of columns hasn't changed, assume that the columns
themselves haven't changed. (I.e. we assume that one `ColumnSeries`
didn't gain a column at the exact same time that another `ColumnSeries`
lost a column.)
"""
if not self._has_variable_columns:
return
new_col_count = self.spec.getNumColumns(self._plp)
if new_col_count == self._cached_col_count:
return
elif new_col_count < self._cached_col_count:
self.beginRemoveColumns(self.INVALID_INDEX,
self._cached_col_count - 1, new_col_count)
self._cached_col_count = new_col_count
self.endRemoveColumns()
else:
self.beginInsertColumns(self.INVALID_INDEX, self._cached_col_count,
new_col_count - 1)
self._cached_col_count = new_col_count
self.endInsertColumns()
# update all of the data in case the columns were really changed
# somewhere other than the right side of the table
upper_left = self.index(0, 0)
lower_right = self.index(self.rowCount() - 1, new_col_count - 1)
self.dataChanged.emit(upper_left, lower_right)
self.headerDataChanged.emit(Qt.Horizontal, 0, new_col_count - 1)
def _onItemsAboutToBeInserted(self, start, end):
self.beginInsertRows(self.INVALID_INDEX, start, end)
def _onItemsInserted(self):
self.endInsertRows()
self._updateVariableColumnsIfNeeded()
def _onItemsAboutToBeRemoved(self, start, end):
self.beginRemoveRows(self.INVALID_INDEX, start, end)
def _onItemsRemoved(self):
self.endRemoveRows()
self._updateVariableColumnsIfNeeded()
def _onItemsAtIndicesReplaced(self, start, end):
upper_left = self.index(start, 0)
lower_right = self.index(end, self.columnCount() - 1)
self.dataChanged.emit(upper_left, lower_right)
def _onItemsReset(self):
self.endResetModel()
self._updateVariableColumnsIfNeeded()
def _onItemChanged(self, item):
idx = _find_param(item, self._plp)
self.rowChanged(idx)
self._updateVariableColumnsIfNeeded()
def data(self, index, role=Qt.DisplayRole):
"""
Provide data from the PLP using the associated abstract param.
See Qt documentation for an explanation of arguments and return value
"""
col = index.column()
this_row = self._plp[index.row()]
if role == ROW_OBJECT_ROLE:
return this_row
value = self.spec.data(col, self._plp, this_row, role)
return value
def setData(self, index, value, role=Qt.EditRole):
"""
Set data for the specified index and role.
See Qt documentation for an explanation of arguments and return value.
"""
col = index.column()
table_row = index.row()
this_row = self._plp[table_row]
self.spec.setData(col, self._plp, this_row, value, role)
return True
def headerData(self, column, orientation, role=Qt.DisplayRole):
"""
Provide column headers, and optionally column tooltips and row numbers.
See Qt documentation for an explanation of arguments and return value
"""
if orientation == Qt.Horizontal:
try:
return self.spec.headerData(column, self._plp, role)
except IndexError:
# we may be in the process of removing columns
return None
def columnCount(self, parent=None):
if self._cached_col_count is None:
self._cached_col_count = self.spec.getNumColumns(self._plp)
return self._cached_col_count
def rowCount(self, parent=None):
return len(self._plp)
def flags(self, index):
"""
See Qt documentation for a method documentation.
"""
flag = Qt.ItemIsEnabled | Qt.ItemIsSelectable
col_num = index.column()
try:
column = self.spec.getColumn(col_num, self._plp)
except (ValueError, TypeError):
# If the request is for a column that isn't defined in self._BaseColumn,
# then assume it's neither editable nor checkable
pass
else:
if column.editable:
flag |= Qt.ItemIsEditable
return flag
def rowChanged(self, row_number):
"""
Call this method when a specific row object has been modified. Will
cause the view to redraw that row.
:param row_number: 0-indexed row number in the model. Corresponds to
the index in the ".rows" iterator.
:type row_number: int
"""
left = self.index(row_number, 0)
right = self.index(row_number, self.columnCount() - 1)
self.dataChanged.emit(left, right)
@property
def rows(self):
"""
Iterate over all rows in the model. If any data is changed, call
rowChanged() method with the row's 0-indexed number to update the view.
"""
for row in self._plp:
yield row
class _PLPSelectionTarget(mappers.TargetMixin, QtCore.QObject):
"""
A mapper target that is provided by PLPTableWidget to allow syncing table
selection state to a second PLP on a model. E.g.
class Model(parameters.CompoundParam):
names = parameters.ParamListParam(item_class=FullName)
selected_names = parameters.ParamListParam(item_class=FullName)
class MyPanel(basewidgets.Panel):
...
def defineMappings(self):
return [(self.name_table, Model.names),
(self.name_table.selection_target, Model.selected_names)]
With this arrangement, the selected rows in the PLPTableWidget will
stay synchronized to panel.model.selected_names.
"""
def __init__(self, table_widget):
super().__init__()
self.table_widget = table_widget
self.connected = False
self._paused = False
self._selection_changed_while_paused = False
def updateConnections(self):
if self.connected:
self.selection_signal.disconnect(self.targetValueChanged)
self.reset_signal.disconnect(self.targetValueChanged)
if self._paused:
self.resumeSelectionSignals()
table_view = self.table_widget.view
selection_signal = table_view.selectionModel().selectionChanged
selection_signal.connect(self.targetValueChanged)
self.selection_signal = selection_signal
self.reset_signal = self.table_widget.table_model.modelReset
self.reset_signal.connect(self.targetValueChanged)
self.connected = True
def targetGetValue(self):
"""
Implementation of an abstract method in TargetMixin.
"""
return self.table_widget.selectedParams()
def targetSetValue(self, value):
"""
Implementation of an abstract method in TargetMixin.
"""
self.table_widget.setSelectedParams(value)
def pauseSelectionSignals(self):
"""
Temporarily stop emitting `targetValueChanged`. This should be done while
the table model is in the process of updating.
"""
if not self.connected or self._paused:
return
self.blockSignals(True)
self._paused = True
self._selection_changed_while_paused = False
self.selection_signal.connect(self._recordSelectionChangeWhilePaused)
self.reset_signal.connect(self._recordSelectionChangeWhilePaused)
def _recordSelectionChangeWhilePaused(self):
"""
Record that `targetValueChanged` should have been emitted, but wasn't
due to a `pauseSelectionSignals` call. `targetValueChanged` will then
be emitted when `resumeSelectionSignals` is called.
"""
self._selection_changed_while_paused = True
def resumeSelectionSignals(self):
"""
Resume emitting `targetValueChanged` after a `pauseSelectionSignals`
call. If the selection was changed during the pause, then
`targetValueChanged` will now be emitted.
"""
if not self._paused:
return
self.blockSignals(False)
self._paused = False
self.selection_signal.disconnect(self._recordSelectionChangeWhilePaused)
self.reset_signal.disconnect(self._recordSelectionChangeWhilePaused)
if self._selection_changed_while_paused:
self.targetValueChanged.emit()