"""
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 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 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 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
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)