Source code for schrodinger.ui.qt.mapperwidgets.plptable

"""
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 headerData(self, col_idx, all_rows, role): col = self.getColumn(col_idx, all_rows) if isinstance(col, ColumnSeries): #FIXME: breaks with multiple column series offset = self.columns.index(col) return col.headerData(col_idx - offset, all_rows, role) else: return col.headerData(all_rows, role)
[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 #===============================================================================
[docs]class PLPTableWidget(mappers.TargetMixin, basewidgets.BaseWidget): """ A table widget for displaying data in a ParamListParam. Compatible with mappers.TargetParamMapper and mappers.MapperMixin. Typically this class is instantiated directly, and defining the behavior of the table is done by creating a custom TableSpec subclass. """
[docs] def __init__(self, *args, spec=None, autospec=False, plp=None, view=None, **kwargs): """ :param spec: Table specification. Will be ignored if autospec is True. :type spec: TableSpec :param autospec: Whether to automatically generate the table specification from the PLP item fields. :type autospec: bool :param plp: ParamListParam containing data. It is more common to set the plp using mappers. :type plp: parameters.ParamListParam :param view: Custom table view instance. If None, an instance of table_helper.SampleDataTableView will be created. :type view: QtWidgets.QTableView """ self.view = view super().__init__(*args, **kwargs) self._autospec = autospec if autospec: spec = None self.setSpec(spec) if plp is not None: self.setPLP(plp)
@property def spec(self): return self._spec @spec.setter def spec(self, _): raise AttributeError('Use setSpec to set a new spec on the table.')
[docs] def setSpec(self, new_spec): """ Apply a new table specification to this table. :param new_spec: the new table spec :type new_spec: TableSpec or None """ if inspect.isclass(new_spec) and issubclass(new_spec, TableSpec): raise TypeError('PLPTableWidget.setSpec must be called with a ' 'TableSpec instance, not a TableSpec subclass.') if not (isinstance(new_spec, TableSpec) or new_spec is None): raise TypeError('PLPTableWidget.setSpec expects a TableSpec ' 'instance or None. Got an argument of type %s.' % type(new_spec)) self._spec = new_spec self.table_model.setSpec(self._spec) if self._spec is not None: sample_data = { idx: col.sample_data for idx, col in enumerate(self._spec.columns) } self.view.setSampleData(sample_data)
[docs] def initSetUp(self): super().initSetUp() self.plp = None self.table_model = _PLPTableModel() # The "viewmodel" is the top model of the widget. This will be updated # whenever we add a proxy model. self.viewmodel = self.table_model if self.view is None: self.view = table_helper.SampleDataTableView() self.selection_target = _PLPSelectionTarget(self) self.view.setSelectionBehavior(QtWidgets.QTableView.SelectRows) # we must connect the pause/resume selection signals before we set the # model on the view. Otherwise, we won't pause our selection signals # until after they've already been emitted. self._connectPauseResumeSelectionSignalsAndSlots(self.viewmodel) self.view.setModel(self.viewmodel) self.selection_target.updateConnections() current_stylesheet = self.view.styleSheet() self.view.setStyleSheet(current_stylesheet + 'QTableView{' 'selection-background-color: #A4BCEE;' 'selection-color: black}')
def _getPauseResumeSelectionSignalsAndSlots(self, model): """ Get (signal, slot) tuples for the connections required to hook up `_PLPSelectionTarget.pauseSelectionSignals` and `resumeSelectionSignals` to the specified Qt model. :param model: The Qt model :type model: QtCore.QAbstractItemModel :return: The required connections. :rtype: tuples(pyqtSignal, Callable) """ signals_and_slots = [] for signal in (model.rowsAboutToBeInserted, model.rowsAboutToBeRemoved, model.rowsAboutToBeMoved, model.columnsAboutToBeInserted, model.columnsAboutToBeRemoved, model.columnsAboutToBeMoved, model.layoutAboutToBeChanged, model.modelAboutToBeReset): signals_and_slots.append( (signal, self.selection_target.pauseSelectionSignals)) for signal in (model.rowsInserted, model.rowsRemoved, model.rowsMoved, model.columnsInserted, model.columnsRemoved, model.columnsMoved, model.layoutChanged, model.modelReset): signals_and_slots.append( (signal, self.selection_target.resumeSelectionSignals)) return signals_and_slots def _connectPauseResumeSelectionSignalsAndSlots(self, model): """ Connect the specified Qt model to `_PLPSelectionTarget.pauseSelectionSignals` and `resumeSelectionSignals`. :param model: The Qt model :type model: QtCore.QAbstractItemModel """ signals_and_slots = self._getPauseResumeSelectionSignalsAndSlots(model) for signal, slot in signals_and_slots: signal.connect(slot) def _disconnectPauseResumeSelectionSignalsAndSlots(self, model): """ Disconnect the specified Qt model from `_PLPSelectionTarget.pauseSelectionSignals` and `resumeSelectionSignals`. :param model: The Qt model :type model: QtCore.QAbstractItemModel """ signals_and_slots = self._getPauseResumeSelectionSignalsAndSlots(model) for signal, slot in signals_and_slots: signal.disconnect(slot)
[docs] def initLayOut(self): super().initLayOut() self.main_layout.addWidget(self.view)
[docs] def addProxy(self, proxy_model): self._disconnectPauseResumeSelectionSignalsAndSlots(self.viewmodel) self._connectPauseResumeSelectionSignalsAndSlots(proxy_model) proxy_model.setSourceModel(self.viewmodel) self.view.setModel(proxy_model) self.viewmodel = proxy_model self.selection_target.updateConnections()
[docs] def targetGetValue(self): """ Implementation of an abstract method in TargetMixin. """ return self.plp
[docs] def targetSetValue(self, value): """ Implementation of an abstract method in TargetMixin. Creates a table model based on the spec, loads the PLP as data, and sets the newly created table model on the view. """ self.setPLP(value)
[docs] def makeAutoSpec(self, plp): spec = TableSpec() item_class = plp.item_class for abstr_par in item_class.getSubParams().values(): # We have to call `generateColumn` on the new columns so # the column can be bound to the spec. We may want to # look at this later if we decide to better support # dynamically adding columns to plptables. spec.columns.append( FieldColumn(abstr_par, title=abstr_par.paramName()).generateColumn(spec)) return spec
[docs] def loadPLP(self, plp): """ Replace the current contents of the table PLP with the contents of the supplied `plp` argument. :param plp: a `ParamListParam` of the same type that is used as the domain model for this table :type plp: parameters.ParamListParam """ self.plp.clear() self.plp.extend(plp)
[docs] 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) """ if isinstance(plp, parameters.ParamListParam): raise TypeError('PLPTableWidget.setPLP must be called with a ' 'concrete PLP, not an abstract PLP.') if plp.__class__.__name__ != 'ListWithSignal': raise TypeError('PLPTableWidget.setPLP expects a concrete PLP. ' 'Got an argument of type %s.' % type(plp)) if self._autospec: spec = self.makeAutoSpec(plp) self.setSpec(spec) if plp is self.plp: # Avoid triggering a model reset if there's no real change return self.table_model.setPLP(plp) self.plp = plp
[docs] def selectedParams(self): # selectedRows() doesn't return the correct value while the table is in # the process of removing columns, so we use selectedIndexes() instead rows = { index.row() for index in self.view.selectionModel().selectedIndexes() } plp_idxs = list(map(self._mapRowNumToPLPIndex, sorted(rows))) return [self.plp[i] for i in plp_idxs]
[docs] def removeSelectedParams(self): """ Deletes parameters if they are selected in the table. """ for param in self.selectedParams(): self.plp.remove(param)
[docs] def setSelectedParams(self, params): """ Selects the table rows corresponding to the specified params. :param params: a list of params to select """ select_plp_idxs = set() for param in params: index = _find_param(param, self.plp) select_plp_idxs.add(index) select_qidxs = set() for row in range(self.viewmodel.rowCount()): plp_idx = self._mapRowNumToPLPIndex(row) if plp_idx in select_plp_idxs: qidx = self.viewmodel.index(row, 0) select_qidxs.add(qidx) selection = QtCore.QItemSelection() for qidx in select_qidxs: selection.select(qidx, qidx) sel_model = self.view.selectionModel() SelectRow = sel_model.ClearAndSelect | sel_model.Rows sel_model.select(selection, SelectRow)
[docs] def removeRow(self, row_num): plp_idx = self._mapRowNumToPLPIndex(row_num) self.plp.pop(plp_idx)
def _mapRowNumToPLPIndex(self, row_num): """ Iterate through the proxy models to get the plp index that `row_num` maps to. :raises IndexError: If `row_num` is greater than any proxy models row count or if a proxy model maps to an invalid index. """ proxy_model = self.viewmodel while proxy_model is not self.table_model: if row_num >= proxy_model.rowCount(): raise IndexError('Row number larger than row count') if row_num < 0: err_msg = ("Row number can't be negative. Is your proxy model " "setup correctly?") raise IndexError(err_msg) qidx = proxy_model.index(row_num, 0) row_num = proxy_model.mapToSource(qidx).row() proxy_model = proxy_model.sourceModel() return row_num
[docs] def exportToCsv(self, filename): table.export_to_csv(self.viewmodel, filename)
[docs] def getRowData(self): """ :return: a list of lists, where each list represents the row of a table :rtype: list[list[object]] """ return table.get_row_data(self.viewmodel)
[docs] def getColumnIndex(self, column): """ 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 :return: Column index. :rtype: int :raises KeyError: If `column` not found in the table. """ return self._spec.getColumnIndex(column, self.plp)
#=============================================================================== # 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()