Source code for schrodinger.ui.qt.table_helper

"""
Classes to help in creating PyQt table models and views
"""

import contextlib
import copy
import enum
import inspect
from itertools import groupby

import decorator

# Maestro instance
from schrodinger import get_maestro
from schrodinger import project
from schrodinger.infra import util
from schrodinger.Qt import QtCore
from schrodinger.Qt import QtGui
from schrodinger.Qt.QtCore import Qt
from schrodinger.ui import maestro_ui
from schrodinger.ui.qt import swidgets
from schrodinger.ui.qt.appframework2 import maestro_callback

maestro = get_maestro()

# A sentinel value used to check if a subclass has specified EDITABLE_COLS or
# UNEDITABLE_COLS
_SENTINEL = object()

# Data row for getting a row object for a table item. Usage example:
# row_obj = QModelIndex.data(ROW_OBJECT_ROLE)
ROW_OBJECT_ROLE = Qt.UserRole + 12345


[docs]def data_method(*roles): """ A decorator for `RowBasedTableModel` and `RowBasedListModel` methods that provide data. The decorator itself must be given arguments of the Qt roles that the method will provide data for. The decorated `RowBasedTableModel` method must take two or three arguments. Two argument methods will be passed: - The column number of the requested data (int) - The `ROW_CLASS` object representing the row to provide data for three argument methods will also be passed: - The Qt role (int) The decorated `RowBasedListModel` method must take one or two arguments. One argument methods will be passed: - The `ROW_CLASS` object representing the row to provide data for Two argument methods will also be passed: - The Qt role (int) See table_helper_example for examples of decorated methods. """ def dec(func): func.data_roles = roles return func return dec
[docs]@decorator.decorator def model_reset_method(func, self, *args, **kwargs): """ A decorator for `RowBasedTableModel` and `RowBasedListModel` methods that reset the data model. See `ModelResetContextMixin` for a context manager version of this. """ self.beginResetModel() try: ret = func(self, *args, **kwargs) finally: self.endResetModel() return ret
[docs]class ModelResetContextMixin: """ A mixin for `QtCore.QAbstractItemModel` subclasses that adds a `modelResetContext` context manager to reset the model. """
[docs] @contextlib.contextmanager def modelResetContext(self): """ A context manager for resetting the model. See `model_reset_method` for a decorator version of this. """ self.beginResetModel() try: yield finally: self.endResetModel()
[docs]class DataMethodDecoratorMixin(object): """ A mixin for `QtCore.QAbstractItemModel` subclasses that use the `data_method` mixin. Subclasses must define `_genDataArgs`. """
[docs] def __init__(self, *args, **kwargs): super(DataMethodDecoratorMixin, self).__init__(*args, **kwargs) self._data_methods = self._collectDataMethods()
def _collectDataMethods(self, method_attr="data_roles"): """ Build a dictionary of all data methods in the provided class instance. By default, this function finds methods decorated with `data_method`. :param method_attr: The attribute used to store roles for a data method. :type method_attr: str :return: A dictionary of {role: method} :rtype: dict """ data_methods = {} for method in util.find_decorated_methods(self, method_attr): roles = getattr(method, method_attr) for cur_role in roles: if cur_role in data_methods: err = ("Multiple data methods for role %s in class %s" % (cur_role, self.__class__.__name__)) raise RuntimeError(err) data_methods[cur_role] = method return data_methods
[docs] def data(self, index, role=Qt.DisplayRole): """ Provide data for the specified index and role. Classes should not redefine this method. Instead, new methods should be created and decorated with `data_method`. See Qt documentation for an explanation of arguments and return value """ if role not in self._data_methods or not index.isValid(): return None data_args = self._genDataArgs(index) + [role] return self._callDataMethod(role, data_args)
def _callDataMethod(self, role, data_args): """ Call the method decorated with `data_method` for the specified role. :param role: The role to get data for :type role: int :param data_args: A list of all potential arguments to pass to the data method. If this list contains more arguments than the method accepts, it will be truncated. :type data_args: list or tuple """ method = self._data_methods[role] code_obj = method.__func__.__code__ # Subtract one because the self argument will be automatically added by # the method num_args = code_obj.co_argcount - 1 # 0x4 constant taken from inspect.py var_args = code_obj.co_flags & 0x4 if var_args or num_args == len(data_args): return method(*data_args) else: return method(*data_args[:num_args]) def _genDataArgs(self, index): """ Return any arguments that should be passed to the data methods. Subclasses must redefine this method. Note that this method must return a list, not a tuple. :param index: The index that data() was called on :type index: `QtCore.QModelIndex` :return: A list of all arugments. If this list contains more arguments than any given data method accepts, it will be truncated when that method is called. :rtype: list """ raise NotImplementedError
[docs]class DataMethodDecoratorProxyMixin(DataMethodDecoratorMixin): """ A mixin for `QtCore.QAbstractProxyModel` subclasses that use the `data_method` mixin. """
[docs] def data(self, proxy_index, role=Qt.DisplayRole): """ Provide data for the specified index and role. Classes should not redefine this method. Instead, new methods should be created and decorated with `data_method`. If no data method for the requested role is found, then the source model's data() method will be called. See Qt documentation for an explanation of arguments and return value """ if not proxy_index.isValid(): return None source_index = self.mapToSource(proxy_index) if role not in self._data_methods: # model.data() is much, much faster than index.data(), so use that return self.sourceModel().data(source_index, role) else: data_args = self._genDataArgs(proxy_index, source_index) + [role] return self._callDataMethod(role, data_args)
def _genDataArgs(self, proxy_index, source_index): """ Return any arguments that should be passed to the data methods. Subclasses may redefine this method. Note that this method must return a list, not a tuple. :param proxy_index: The index that data() was called on. :type proxy_index: `QtCore.QModelIndex` :param source_index: The source model index that `proxy_index` maps to. :type source_index: `QtCore.QModelIndex` :return: A list of all arugments. If this list contains more arguments than any given data method accepts, it will be truncated when that method is called. :rtype: list """ return [proxy_index, source_index]
[docs]class RowBasedModelMixin(ModelResetContextMixin, DataMethodDecoratorMixin): """ A table model where data is organized in rows. This class is intended to be subclassed and should not be instantiated directly. All subclasses must redefine `COLUMN` and must include at least one method decorated with `data_method`. Subclasses must also redefine: - `ROW_CLASS` if `appendRow` is to be used - {_setData} if any columns are editable Data may be added to the table using `loadData` or `appendRow`. Data may be deleted using `removeRow` or `removeRows`. Subclass methods that reset the model may use the `model_reset_method` decorator. :cvar Column: A class describing the table's columns. See `TableColumns`. :vartype Column: `TableColumns` :cvar ROW_CLASS: A class that represents a single row of the table. ROW_CLASS must be defined in any subclasses that use `appendRow` :vartype ROW_CLASS: type :cvar ROW_LIST_OFFSET: The index of the first element in self._rows. Setting this value to 1 allows the class to be used with one-indexed lists. :vartype ROW_LIST_OFFSET: int :cvar SHOW_ROW_NUMBERS: Whether to show row numbers in the vertical header. :vartype SHOW_ROW_NUMBERS: bool The following class variables are the deprecated way of specifying columns. They may not be given if `Column` is used: :cvar COLUMN: May not be given if `Column` is used. A alternative method for describing the table's columns. `Column` should be preferred for newly created RowTableModel subclasses. A class containing constants describing the table columns. COLUMN must also include the following attributes: (HEADERS: A list of column headers (list), NUM_COLS: The number of columns in the table (int), TOOLTIPS (optional): A list of column header tooltips (list)). :vartype COLUMN: type :cvar EDITABLE_COLS: May not be given if `Column` is used. Use `editable=True` in the `TableColumn` declaration instead. A list of column numbers for columns that should be flagged as editable. Note that only one of EDITABLE_COLS and `UNEDITABLE_COLS` may be provided. If neither are provided, then no columns will be editable. :vartype EDITABLE_COLS: list :cvar UNEDITABLE_COLS: May not be given if `Column` is used. Use `editable=False` in the `TableColumn` declaration instead. A list of column numbers for columns that should be flagged as uneditable. Not necessary if `COLUMN` is a `TableColumns` object. Note that only one of `EDITABLE_COLS` and UNEDITABLE_COLS may be provided. If neither are provided, then no columns will be editable. :vartype UNEDITABLE_COLS: list :cvar CHECKABLE_COLS: May not be given if `Column` is used. Use `checkable=True` in the `TableColumn` declaration instead. A list of column numbers for columns that should be flagged as user checkable. :vartype CHECKABLE_COLS: list :cvar NO_DATA_CHANGED: A flag that can be returned from `_setData` to indicate that setting the data succeeded, but that there's no need to emit a `dataChanged` signal. :vartype NO_DATA_CHANGED: object """ COLUMN = None Column = None EDITABLE_COLS = _SENTINEL UNEDITABLE_COLS = _SENTINEL CHECKABLE_COLS = () ROW_CLASS = None ROW_LIST_OFFSET = 0 SHOW_ROW_NUMBERS = False NO_DATA_CHANGED = object()
[docs] def __init__(self, parent=None): super(RowBasedModelMixin, self).__init__(parent) self._rows = [] if self.COLUMN is not None: if (inspect.isclass(self.COLUMN) and issubclass(self.COLUMN, TableColumns)): raise ValueError("Use Column for TableColumns enums, not " "COLUMN.") elif self.Column is None: self._checkEditableCols() self._createTableColumnsObject() else: raise ValueError("May not specify both Column and COLUMN.") # Create an alignment method if applicable if (Qt.TextAlignmentRole not in self._data_methods and self.Column is not None and any(col.align is not None for col in self.Column)): self._data_methods[Qt.TextAlignmentRole] = self._textAlignmentData
def __deepcopy__(self, memo): """ Deepcopy the model, keeping the same parent. Subclasses are responsible for making sure any object stored in a row can be deepcopied. """ new_model = self.__class__(self.parent()) new_model._rows = copy.deepcopy(self._rows, memo) return new_model def _checkEditableCols(self): """ Update `EDITABLE_COLS` based on the contents of `UNEDITABLE_COLS` """ if (self.EDITABLE_COLS is not _SENTINEL and self.UNEDITABLE_COLS is not _SENTINEL): err = "Cannot define both EDITABLE_COLS and UNEDITABLE_COLS" raise ValueError(err) elif self.UNEDITABLE_COLS is not _SENTINEL: self.EDITABLE_COLS = [ i for i in range(self.COLUMN.NUM_COLS) if i not in self.UNEDITABLE_COLS ] elif self.EDITABLE_COLS is _SENTINEL: self.EDITABLE_COLS = [] prob_cols = set(self.CHECKABLE_COLS).intersection(self.EDITABLE_COLS) if prob_cols: err = ("Columns {0} cannot be in both EDITABLE_COLS and " "CHECKABLE_COLS").format(', '.join(map(str, list(prob_cols)))) raise ValueError(err) def _createTableColumnsObject(self): """ If `self.COLUMNS` is given instead of `self.Column`, create an equivalent `TableColumns` object and assign it to `self.Column`. """ if inspect.isclass(self.COLUMN): class_name = self.COLUMN.__name__ else: class_name = self.COLUMN.__class__.__name__ columns = _TableColumnsMeta.__prepare__(class_name, (TableColumns,)) for i, cur_header in enumerate(self.COLUMN.HEADERS): try: cur_tooltip = self.COLUMN.TOOLTIPS[i] except AttributeError: cur_tooltip = None cur_column = Column(cur_header, tooltip=cur_tooltip, editable=i in self.EDITABLE_COLS, checkable=i in self.CHECKABLE_COLS) col_name = "Column%i" % i columns[col_name] = cur_column self.Column = _TableColumnsMeta(class_name, (TableColumns,), columns)
[docs] @model_reset_method def reset(self): """ Remove all data from the model """ self._rows = []
[docs] def columnCount(self, parent=None): # See Qt documentation for method documentation return len(self.Column)
[docs] def rowCount(self, parent=None): # See Qt documentation for method documentation return len(self._rows)
def _getRowFromIndex(self, index): """ Return a row object from the given QModelIndex into the model. """ return self._rows[index.row() + self.ROW_LIST_OFFSET] @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._rows: yield row
[docs] 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)
[docs] def columnChanged(self, col_number): """ Call this method when a specific column object has been modified. Will cause the view to redraw that column. :param col_number: 0-indexed column number in the model. :type col_number: int """ top = self.index(0, col_number) bottom = self.index(self.rowCount() - 1, col_number) self.dataChanged.emit(top, bottom)
@data_method(ROW_OBJECT_ROLE) def _rowObjectData(self, column, row_obj): return row_obj def _textAlignmentData(self, column, row_obj): """ A method for Qt.TextAlignmentRole data. Note that this method is only added as a data method if: - self.COLUMN is a TableColumns object that specifies alignment and - the subclass has not specified another method for Qt.TextAlignmentRole data. See `data_method` for argument and return value documentation. """ return self.Column(column).align
[docs] @model_reset_method def loadData(self, rows): """ Load data into the table and replace all existing data. :param rows: A list of `ROW_CLASS` objects :type rows: list """ self._rows = rows
[docs] def appendRow(self, *args, **kwargs): """ Add a row to the table. All arguments are passed to `ROW_CLASS` initialization. :return: The row number of the new row :rtype: int """ row = self.ROW_CLASS(*args, **kwargs) return self.appendRowObject(row)
[docs] def appendRowObject(self, row): """ Add a row to the table. :param row: Row object to add to the table. :type row: `ROW_CLASS` :return: The row number of the new row :rtype: int """ num_rows = len(self._rows) self.beginInsertRows(QtCore.QModelIndex(), num_rows, num_rows) self._rows.append(row) self.endInsertRows() return num_rows
[docs] def removeRows(self, row, count, parent=None): # See Qt documentation for method documentation self.beginRemoveRows(QtCore.QModelIndex(), row, row + count - 1) start = row + self.ROW_LIST_OFFSET del self._rows[start:start + count] self.endRemoveRows() return True
[docs] def removeRowsByRowNumbers(self, rows): """ Remove the given rows from the model, specified by row number, 0-indexed. """ rows = sorted(rows) # Group the rows by range: groups = [] for k, group in groupby(enumerate(rows), lambda i_x: i_x[0] - i_x[1]): group_rows = [x[1] for x in group] start = group_rows[0] count = group_rows[-1] - start + 1 groups.append((start, count)) # Remove rows in reverse order: for start, count in sorted(groups, reverse=True): self.removeRows(start, count)
[docs] def removeRowsByIndices(self, indices): """ Remove all rows from the model specified by the given QModelIndex items. """ # Get row number for each index: rows = {index.row() for index in indices} self.removeRowsByRowNumbers(rows)
[docs] def headerData(self, section, 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: if role == Qt.DisplayRole: return self.Column(section).title elif role == Qt.ToolTipRole: return self.Column(section).tooltip else: if role == Qt.DisplayRole and self.SHOW_ROW_NUMBERS: return section + 1
[docs] def flags(self, index): """ See Qt documentation for an method documentation. """ flag = Qt.ItemIsEnabled | Qt.ItemIsSelectable col_num = index.column() try: column = self.Column(col_num) except (ValueError, TypeError): # If the request is for a column that isn't defined in self.Column, # then assume it's neither editable nor checkable pass else: if column.editable: flag |= Qt.ItemIsEditable if column.checkable: flag |= Qt.ItemIsUserCheckable return flag
[docs] def setData(self, index, value, role=Qt.EditRole): """ Set data for the specified index and role. Whenever possible, sub- classes should redefine `_setData` rather than this method. See Qt documentation for an explanation of arguments and return value. """ col = index.column() table_row = index.row() row_data = self._getRowFromIndex(index) retval = self._setData(col, row_data, value, role, table_row) if retval is False: return False elif retval is self.NO_DATA_CHANGED: return True else: self.dataChanged.emit(index, index) return True
def _setData(self, col, row_data, value, role, row_num): """ Set data for the specified index and role. This method should be reimplemented in any subclasses that contain editable columns. :param col: The column to set data for :type col: int :param row_data: The ROW_CLASS instance to modify :type row_data: ROW_CLASS :param value: The value to set :param value: object :param role: The role to set data for :type role: int :param row_num: The row number :type row_num: int :return: `False` if setting failed. `self.NO_DATA_CHANGED` if setting succeeded but no `dataChanged` signal should be emitted. All other values are considered successes and will result in a `dataChanged` signal being emitted for the modified index. :rtype: object """ return False def _genDataArgs(self, index): # See DataMethodDecoratorMixin for method documentation col = index.column() row_data = self._getRowFromIndex(index) return [col, row_data]
[docs] def formatFloat(self, value, role, digits, fmt=""): """ Format floating point values for display or sorting. If `role` is `Qt.DisplayRole`, then `value` will be returned as a string with the specified formatting. All other `role` values are assumed to be a sorting role and value will be returned unchanged. :param value: The floating point value to format :type value: float :param role: The Qt data role :type role: int :param digits: The number of digits to include after the decimal point for Qt.DisplayRole :type digits: int :param fmt: Additional floating point formatting options :type fmt: str :return: The formatted or unmodified value :rtype: str or float """ if role == Qt.DisplayRole: return "{0:{2}.{1}f}".format(value, digits, fmt) else: return value
[docs] def af2SettingsGetValue(self): """ This function adds support for the settings mixin. It allows to save table cell values in case this table is included in the settings panel. Returns list of rows if table model is of RowBasedTableModel class type. :return: list of rows in tbe table's model. :rtype: list or None """ rows = copy.deepcopy(self._rows) return rows
[docs] @model_reset_method def af2SettingsSetValue(self, value): """ This function adds support for the settings mixin. It allows to set table cell values when this table is included in the settings panel. :param value: settings value, which is a list of row data here. :type value: list """ self._rows = copy.deepcopy(value)
[docs] def replaceRows(self, new_rows): """ Replace the contents of the model with the contents of the given list. The change will be presented to the view as a series of row insertions and deletions rather than as a model reset. This allows the view to properly update table selections and scroll bar position. This method may only be used if: - the `ROW_CLASS` objects can be compared using < and == - the contents of the model (i.e. `self._rows`) are sorted in ascending order - the contents of `new_rows` are sorted in ascending order This method is primarily intended for use when the table contains rows based on project table rows. On every project change, the project table can be reread and used to generate `new_list` and this method can then properly update the model. :param new_rows: A list of `ROW_CLASS` objects :type new_rows: list """ new_rows = new_rows[:] rows_index = 0 blank_index = QtCore.QModelIndex() while new_rows: if rows_index >= len(self._rows): self.beginInsertRows(blank_index, rows_index, rows_index + len(new_rows) - 1) self._rows.extend(new_rows) self.endInsertRows() break cur_new_row = new_rows[0] cur_old_row = self._rows[rows_index] if cur_new_row < cur_old_row: del new_rows[0] self.beginInsertRows(blank_index, rows_index, rows_index) self._rows.insert(rows_index, cur_new_row) self.endInsertRows() rows_index += 1 elif cur_new_row == cur_old_row: del new_rows[0] self._rows[rows_index] = cur_new_row self.rowChanged(rows_index) rows_index += 1 else: # cur_new_row > cur_old_row self.beginRemoveRows(blank_index, rows_index, rows_index) del self._rows[rows_index] self.endRemoveRows() else: len_rows = len(self._rows) if rows_index < len_rows: self.beginRemoveRows(blank_index, rows_index, len_rows - 1) del self._rows[rows_index:] self.endRemoveRows()
[docs]class RowBasedTableModel(RowBasedModelMixin, QtCore.QAbstractTableModel): pass
[docs]class RowBasedListModel(RowBasedModelMixin, QtCore.QAbstractTableModel): """ A model class for use with `QtWidgets.QListView` views. The model has no headers and only one column. Note that the `Column` class variable is not needed. """
[docs] def columnCount(self, parent=None): # See Qt documentation for method documentation return 1
[docs] def headerData(self, section, orientation, role=Qt.DisplayRole): # See Qt documentation for method documentation return None
[docs] def index(self, row, column=0, parent=None): # See Qt documentation for method documentation return super(RowBasedListModel, self).index(row, column)
def _genDataArgs(self, index): # See DataMethodDecoratorMixin for method documentation row_data = self._getRowFromIndex(index) return [row_data] @data_method(ROW_OBJECT_ROLE) def _rowObjectData(self, row_obj): return row_obj
[docs]class PythonSortProxyModel(QtCore.QSortFilterProxyModel): """ A sorting proxy model that uses Python (rather than C++) to compare values. This allows Python lists, tuples, and custom classes to be properly sorted. :cvar SORT_ROLE: If specified in a subclass, this value will be used as the sort role. Otherwise, Qt defaults to Qt.DisplayRole. :vartype SORT_ROLE: int :cvar DYNAMIC_SORT_FILTER: If specified in a subclass, this value will be used as the dynamic sorting and filtering setting (see `QtCore.QSortFilterProxyModel.setDynamicSortFilter`). Otherwise, Qt defaults to False in Qt4 and True in Qt5. :vartype DYNAMIC_SORT_FILTER: bool """ SORT_ROLE = None DYNAMIC_SORT_FILTER = None
[docs] def __init__(self, parent=None): super(PythonSortProxyModel, self).__init__(parent) if self.SORT_ROLE is not None: self.setSortRole(self.SORT_ROLE) if self.DYNAMIC_SORT_FILTER is not None: self.setDynamicSortFilter(self.DYNAMIC_SORT_FILTER)
[docs] def lessThan(self, left, right): """ Comparison method for sorting rows and columns. Handle special case in which one or more sort data values is `None` by evaluating it as less than every other value. :param left: table cell index :type left: QtCore.QModelIndex :param right: table cell index :type right: QtCore.QModelIndex See Qt documentation for full method documentation. """ sort_role = self.sortRole() left_data = left.data(sort_role) right_data = right.data(sort_role) if right_data is None: return False elif left_data is None: return True try: return left_data < right_data except TypeError as err: raise TypeError('%s (%s, %s)' % (err, left_data, right_data))
[docs]class SampleDataTableViewMixin: """ A table view mixin that uses sample data to properly size columns. Additionally, the table size hint will attempt to display the full width of the table. :cvar SAMPLE_DATA: A dictionary of {column number: sample string}. Any columns that do not appear in this dictionary will not be resized. Can be set by passing `sample_data` to `__init__` or by calling `setSampleData` after instantiation. :vartype SAMPLE_DATA: dict :cvar MARGIN: The additional width to add to each column included in `SAMPLE_DATA` :vartype MARGIN: int """ SAMPLE_DATA = {} MARGIN = 20
[docs] def __init__(self, parent=None, sample_data=None): super().__init__(parent) if sample_data is not None: # copy the class variable to an instance variable so we don't modify # the class variable self.SAMPLE_DATA = self.SAMPLE_DATA.copy() self.SAMPLE_DATA.update(sample_data)
def _updateColumnWidths(self): for col_num in self.SAMPLE_DATA: # Note that resizeColumnsToContents() and resizeColumnToContents() # (with and without an "s" after "Column") aren't equivalent. The # two methods use different techniques to resize columns. # resizeColumnToContents() (no "s") takes both sizeHintForColumn() # and the header width into account, so that's what we use here. self.resizeColumnToContents(col_num) self.updateGeometry()
[docs] def setModel(self, model): """ After setting the model, resize the columns using `SAMPLE_DATA` and the header data provided by the model See Qt documentation for an explanation of arguments """ super().setModel(model) self._updateColumnWidths()
[docs] def setSampleData(self, new_sample_data): """ Sets SAMPLE_DATA to new_sample_data and updates column widths if model is set. :param new_sample_data: The new sample data :type new_sample_data: dict """ self.SAMPLE_DATA = new_sample_data if self.model() is not None: self._updateColumnWidths()
[docs] def sizeHintForColumn(self, col_num): """ Provide a size hint for the specified column using `SAMPLE_DATA`. Note that this method does not take header width into account as the header width is already accounted for in `resizeColumnToContents`. See Qt documentation for an explanation of arguments and return value """ font = self.font() font_metrics = QtGui.QFontMetrics(font) col_data = self.SAMPLE_DATA.get(col_num, "") width = font_metrics.horizontalAdvance(col_data) + self.MARGIN return width
[docs] def sizeHint(self): """ Provide a size hint that requests the full width of the table. See Qt documentation for an explanation of arguments and return value """ height = super().sizeHint().height() width = self.horizontalHeader().length() width += self._verticalHeaderWidth() width += 2 * self.frameWidth() scroll_bar = self.verticalScrollBar() if scroll_bar.isVisibleTo(self): width += scroll_bar.width() return QtCore.QSize(width, height)
def _verticalHeaderWidth(self): """ Return the width that should be allocated for the vertical header. If the vertical header is visible but there's no data in the model yet, then allocated four pixels for the row selection bar, which will be shown as soon as data is loaded into the model. :note: This method assumes that there are no row headers. If row headers are needed, then this class should be modified to accept row header sample data. The sample data can then be used here to properly allocate width for the vertical header. """ vheader = self.verticalHeader() vheader_width = vheader.width() vheader_visible = vheader.isVisibleTo(self) if vheader_visible and vheader_width == 0: return 4 else: return vheader_width
[docs]class SampleDataTableView(SampleDataTableViewMixin, swidgets.STableView): """ See SampleDataTableViewMixin for features. """ pass
class _UserRolesEnumDict(enum._EnumDict): """ A UserRolesEnum namespace dictionary that auto-assigns role numbers. """ def __init__(self, step_size): super().__init__() self._step_size = step_size self._cur_val = Qt.UserRole def setCurrentValue(self, offset): """ Set the value to try for the next role that we auto-assign a number to. :param offset: The next value to try to assign. (If the enum class already has a role with that number, it will continue to search for a unique value.) :type offset: int """ self._cur_val = Qt.UserRole + offset def __setitem__(self, key, value): if not key.startswith("_"): if not value or isinstance(value, enum.auto): value = self._cur_val while value in self.values(): value += self._step_size self._cur_val = value + self._step_size super().__setitem__(key, value) # UserRolesEnumMeta requires that UserRolesEnum is defined, but # UserRolesEnumMeta is used to define UserRolesEnum, so we define a dummy # UserRolesEnum value here. UserRolesEnum = None
[docs]class UserRolesEnumMeta(enum.EnumMeta): """ The metaclass for `UserRolesEnum`. See `UserRolesEnum` for documentation. """ def __new__(metacls, cls, bases, classdict, offset=None, step_size=None): offset, step_size = metacls._getOffsetAndStepSize( bases, offset, step_size) class_obj = super().__new__(metacls, cls, bases, classdict) class_obj._OFFSET = offset class_obj._STEP_SIZE = step_size return class_obj @classmethod def __prepare__(metacls, cls, bases, offset=None, step_size=None): offset, step_size = metacls._getOffsetAndStepSize( bases, offset, step_size) classdict = _UserRolesEnumDict(step_size) metacls._inheritRoles(bases, classdict) classdict.setCurrentValue(offset) return classdict @classmethod def _getOffsetAndStepSize(metacls, bases, offset=None, step_size=None): """ Get the appropriate values for offset and step size for the UserRolesEnum subclass being created. These values are used to auto-number roles. :param bases: The classes that the class being created inherits from. :type bases: tuple :param offset: The value for the first auto-numbered role. Note that Qt.UserRole is added to this number as all user created roles must be greater than or equal to that. Defaults to 0. :type offset: int :param step_size: The interval between auto-numbered role numbers. Defaults to 1. :type step_size: int :raise ValueError: If step size is 0. """ if offset is None: offset = metacls._getInheritedValue("_OFFSET", 0, bases) if step_size is None: step_size = metacls._getInheritedValue("_STEP_SIZE", 1, bases) if step_size == 0: raise ValueError("Cannot use a step size of 0") return offset, step_size @staticmethod def _getInheritedValue(name, default, bases): """ Determine the appropriate value for attribute `name` in the class being created. :param name: The attribute name. :type name: str :param default: The default value to use if no value has been specified or inherited. :type default: object :param bases: The classes that the class being created inherits from. :type bases: tuple :param classdict: The dictionary of attributes for the class being created. :type classdict: dict """ for cur_base in bases: val = getattr(cur_base, name, None) if val is not None: return val return default @staticmethod def _inheritRoles(bases, classdict): """ Determine if there are any enum members that the class being created should inherit. :param bases: The classes that the class being created inherits from. :type bases: tuple :param classdict: The dictionary of attributes for the class being created. :type classdict: dict """ if UserRolesEnum is None: # We can't check if this class is a grandchild of UserRolesEnum if # we're in the process of defining UserRolesEnum return for cur_base in bases: if issubclass(cur_base, UserRolesEnum) and cur_base._member_names_: for cur_role in cur_base: if cur_role.name not in classdict: classdict[cur_role.name] = cur_role.value def __call__(cls, value, names=None, module=None, type=None, offset=None, step_size=None): if names is None: return super(UserRolesEnumMeta, cls).__call__(value, names, module, type) if isinstance(names, str): names = names.replace(',', ' ').split() if offset is None: offset = getattr(cls, "_OFFSET", 0) if step_size is None: step_size = getattr(cls, "_STEP_SIZE", 1) if step_size == 0: raise ValueError("Cannot use a step size of 0") new_names = [] existing_values = {role.value for role in cls} i = 0 for cur_name in names: while True: role_num = i * step_size + Qt.UserRole + offset i += 1 if role_num not in existing_values: break new_names.append((cur_name, role_num)) class_obj = super(UserRolesEnumMeta, cls).__call__(value, new_names, module=module, type=type) class_obj._OFFSET = offset class_obj._STEP_SIZE = step_size return class_obj @staticmethod def _get_mixins_(*args): # See parent class for method documentation. This method is overridden # here so that UserRolesEnum classes can inherit enum members if len(args) > 1: # https://github.com/python/cpython/commit/3f4012117bf80aa7c005f8fa6fb8e1f8b1aef5d5 cls, bases = args else: # < python 3.8.5 bases = args[0] return int, bases[-1]
UserRolesEnum = UserRolesEnumMeta( "UserRolesEnum", (enum.IntEnum,), (enum.EnumMeta.__prepare__("UserRolesEnum", (enum.IntEnum,)))) UserRolesEnum.__doc__ = """ An enum for defining custom Qt user roles. Roles can be defined as either:: CustomRole = UserRolesEnum("CustomRole", ["Sort", "ResName", "ResNum"]) or as :: class CustomRole(UserRolesEnum): Sort = () ResName = () ResNum = () All roles will be automatically numbered sequentially starting at Qt.UserRole. It is possible to change the starting role number by specifying an offset, where the first role will then be Qt.UserRole plus the offset. It's also possible to adjust the step size between roles by specifying step_size. (Note that step_size is only useful in uncommon scenarios, such as when generating groups of roles.) Examples of offset and step size:: CustomRole = UserRolesEnum("CustomRole", ["Sort", "ResName", "ResNum"], offset=10, step_size=2) class CustomRole(UserRolesEnum, offset=10, step_size=2): # Note that specifying offset and step_size using class syntax only # works under Python 3. Sort = () ResName = () ResNum = () """
[docs]class Column(object): """ A table column. This class is intended to be used in the `TableColumns` enum. """ # A count of how many Column objects have been initialized _count = 0
[docs] def __init__(self, title=None, tooltip=None, align=None, editable=False, checkable=False): """ :param title: The column title to display in the header. :type title: str :param tooltip: The tooltip to display when the user hovers over the column header. :type tooltip: str :param align: The alignment for cells in the column. If not given, Qt defaults to left alignment. :type align: int :param editable: Whether cells in the column are editable. (I.e., whether cells should be given the Qt.ItemIsEditable flag.) :type editable: bool :param checkable: Whether cells in the column can be checked or unchecked without opening an editor. (I.e., whether cells should be given the Qt.ItemIsUserCheckable flag.) Note that cells in checkable columns should provide data for Qt.CheckStateRole. :type checkable: bool """ if editable and checkable: raise ValueError("A column cannot be both editable and checkable.") self.data = { "title": title, "tooltip": tooltip, "align": align, "editable": editable, "checkable": checkable } # keep track of the order that Column objects are instantiated in so # that we can sort TableColumns members in declaration order self._count = self.__class__._count self.__class__._count += 1
class _Column(int): """ A table column. This class is intended to be used in the `TableColumns` enum. `_Column` objects should not be directly instantiated. `TableColumns` values should instead be given as `Column` objects. These objects will then be converted to `_Column` objects during `TableColumns` instantiation. (See `_TableColumnsMeta.__new__`.) """ def __new__(cls, val, **kwargs): """ :param val: The integer value for this object. :type val: int """ self = super(_Column, cls).__new__(cls, val) for k, v in kwargs.items(): setattr(self, k, v) return self def __str__(self): return self.__repr__() def __repr__(self): return "Column %i (%s)" % (self, self.title) class _ColumnEnumValue(_Column): """ A `_Column` object that is created during a `TableColumns` instantiation. """ def __new__(cls, self): # Enum creation requires _value_ and value attributes if not isinstance(self, _Column): raise ValueError("TableColumns members must be Column objects.") self._value_ = int(self) self.value = int(self) return self class _TableColumnsEnumDict(enum._EnumDict): """ An enum namespace dictionary that converts `Column` objects to `_Column` objects. """ def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self._col_count = 0 def __setitem__(self, key, value): if isinstance(value, Column): value = _Column(self._col_count, **value.data) self._col_count += 1 super().__setitem__(key, value) class _TableColumnsMeta(enum.EnumMeta): """ A metaclass for `TableColumns`. """ @classmethod def __prepare__(metacls, cls, bases): return _TableColumnsEnumDict() TableColumns = _TableColumnsMeta( "TableColumns", (_ColumnEnumValue, enum.Enum), enum.EnumMeta.__prepare__("TableColumns", (_ColumnEnumValue, enum.Enum))) # yapf: disable TableColumns.__doc__ = """ An enum for listing table columns. Each enum member should be given as a `Column` object. Column order will be based on declaration order. """
[docs]def connect_signals(model, signal_map): """ Connect all specified signals :param model: The model to connect signals from :type model: `QtCore.Qbject` :param signal_map: A dictionary of {signal name (str): slot}. :type signal_map: dict """ for signal_name, slot in signal_map.items(): signal = getattr(model, signal_name) signal.connect(slot)
[docs]def disconnect_signals(model, signal_map): """ Disconnect all specified signals :param model: The model to disconnect signals from :type model: `QtCore.Qbject` :param signal_map: A dictionary of {signal name (str): slot}. :type signal_map: dict """ for signal_name, slot in signal_map.items(): signal = getattr(model, signal_name) signal.disconnect(slot)
# Role for getting the entry id for a table row PtRowBasedCustomRole = UserRolesEnum("CustomRole", ("EntryId"), offset=123456) # Possible values when setting CheckStateRole data of "In" columne of the # PtRowBasedTableModel table. Range is currently ignored, but planned to be # implemented in the future. Include = enum.IntEnum("Include", ["Only", "Toggle", "Range"]) # TODO: Either move to mmshare/python/scripts/autots_gui_dir/results_table.py # or move into a new module, along with other relevant classes.
[docs]class PtRowBasedTableModel(maestro_callback.MaestroCallbackMixin, RowBasedTableModel): """ A table model that keeps track of the inclusion state of an entry between the Project Table and the table's inclusion checkboxes. The inclusion lock state is also respected by not allowing the user to uncheck a inclusion locked entry. Note: An 'Inclusion' column must be defined by the Column class as well as an 'EntryId' CustomRole to utilize this class. Moreover, the row object class for the PtRowBasedTableModel subclass should define an entry_id attribute, otherwise subclass needs to define a data method for PtRowBasedCustomRole.EntryId. Lastly, if the subclass of PtRowBasedTableModel requires any additional custom roles, it should use a UserRolesEnum that inherits from the above PtRowBasedCustomRole to avoid the risk of role number conflicts. """
[docs] def __init__(self): super(PtRowBasedTableModel, self).__init__() self._pt = maestro.project_table_get() workspace_hub = maestro_ui.WorkspaceHub.instance() # keep track of inclusion changes from workspace workspace_hub.inclusionChanged.connect(self.onInclusionChanged)
[docs] def onInclusionChanged(self): """ Called when the workspace's inclusion changes. The emitted dataChanged() signal forces the view to update each entry's inclusion state from the workspace by calling data(). """ self.dataChanged.emit( self.index(self.Column.Inclusion, 0), self.index(self.Column.Inclusion, self.columnCount() - 1))
[docs] @maestro_callback.project_close def onProjectClosed(self): """ Reset the table when a project is closed to avoid invalid data. """ self.reset() self._pt = None
[docs] @maestro_callback.project_updated def onProjectUpdated(self): """ Reset the PT instance. """ if not self._pt: self._pt = maestro.project_table_get()
@data_method(PtRowBasedCustomRole.EntryId) def _getEntryId(self, col, data_row, role): """ Get the Entry ID for a specified row. See table_helper.RowBasedTableModel.data_method() for documentation on arguments and return value. """ return data_row.entry_id
[docs] def data(self, index, role=Qt.DisplayRole): """ If the inclusion state is requested, the data will be retrieved from the PT. The inclusion states are not stored in the table to avoid updating in two locations. See table_helper.RowBasedTableModel.data() for documentation on arguments and return value. """ if (role == Qt.CheckStateRole and index.column() == self.Column.Inclusion): entry_id = self.data(index, PtRowBasedCustomRole.EntryId) row = self._pt.getRow(entry_id) if row.in_workspace: return Qt.Checked else: return Qt.Unchecked else: return super(PtRowBasedTableModel, self).data(index, role)
[docs] def setData(self, index, value, role=Qt.EditRole): """ If the inclusion state is updated, the data will be set to the PT. See table_helper.RowBasedTableModel.data() for documentation on arguments and return value. """ if (role == Qt.CheckStateRole and index.column() == self.Column.Inclusion): entry_id = self.data(index, PtRowBasedCustomRole.EntryId) row = self._pt.getRow(entry_id) if value == Include.Toggle: if not row.in_workspace: # Do not "unfix" entries that are currently fixed: if row.in_workspace != project.LOCKED_IN_WORKSPACE: row.in_workspace = project.IN_WORKSPACE else: # On uncheck, exclude even if the entry is fixed row.in_workspace = project.NOT_IN_WORKSPACE elif value == Include.Only: # Without control/command key, it doesn't matter what the # previous inclusion state of the entry was, we always include # it (unless it's always fixed in Workspace) and exclude other # (unfixed) entries: row.includeOnly() return True else: return super(PtRowBasedTableModel, self).setData(index, value, role)