import schrodinger
from schrodinger.Qt import QtCore
from schrodinger.Qt import QtGui
from schrodinger.Qt import QtWidgets
from schrodinger.Qt.QtCore import Qt
from ... import utils as gui_utils
maestro = schrodinger.get_maestro()
SORT_ROLE, ATOMS_ROLE, ATOM_NUMS_ROLE, STRUCTURE_ROLE = list(
range(Qt.UserRole + 1, Qt.UserRole + 5))
# TODO: add a "Remove Row" and a "Remove All" to the menu?
[docs]class SubTabTableView(gui_utils.ProjTableLikeView):
"""
A table view with a right click menu for removing selected rows. This class
may be subclassed to configure delegates.
:cvar COLUMN: A class containing column constants for the table.
:vartype COLUMN: type
:cvar SAMPLE_DATA: A dictionary of {column number: sample cell contents}.
The sample cell contents are used to set column widths appropriately. Note
that sample data for columns COLUMN.ID, COLUMN.TITLE, COLUMN.ATOM, and/or
COLUMN.ATOMS will be added automatically if not explicitly included. Any
other columns that are not included here will be set to their default width.
:vartype SAMPLE_DATA: dict
:cvar _DEFAULT_SAMPLE_DATA: A dictionary of {column name (attribute in the
COLUMN class variable): sample cell contents}. The sample cell contents are
used to set column widths appropriately. If no column of the specified name
is found, the sample data will be ignored. Data found in SAMPLE_DATA takes
precedence over data found in _DEFAULT_SAMPLE_DATA. Subclasses should alter
SAMPLE_DATA rather than _DEFAULT_SAMPLE_DATA whenever possible (due to
increased ease of debugging typos).
:vartype _DEFAULT_SAMPLE_DATA: dict
:cvar MARGIN: The additional width to add to each column included in
the sample data
:vartype MARGIN: int
"""
COLUMN = None
SAMPLE_DATA = {}
_DEFAULT_SAMPLE_DATA = {
"ID": "999",
"TITLE": "This is a very long entry title",
"ATOM": "C999",
"ATOMS": "C99, C99, C99, C99"
}
MARGIN = 20
setMarkerHighlighting = QtCore.pyqtSignal(list, bool)
[docs] def __init__(self, parent=None):
super(SubTabTableView, self).__init__(parent)
self.right_click_menu = QtWidgets.QMenu(self)
self.right_click_menu.addAction("Remove selected",
self.removeSelectedRows)
self.all_sample_data = self._getSampleData()
[docs] def removeSelectedRows(self):
"""
Remove the currently selected rows from the table
"""
to_delete = {index.row() for index in self.selectedIndexes()}
to_delete = sorted(to_delete, reverse=True)
model = self.model()
for row in to_delete:
model.removeRow(row)
[docs] def mouseReleaseEvent(self, event):
"""
Create the popup menu when the user right clicks
"""
super(SubTabTableView, self).mouseReleaseEvent(event)
if event.button() == Qt.RightButton:
self.right_click_menu.popup(event.globalPos())
event.accept()
def _getSampleData(self):
"""
Combine `_DEFAULT_SAMPLE_DATA` and `SAMPLE_DATA`
:return: The combined contents of `_DEFAULT_SAMPLE_DATA` and
`SAMPLE_DATA`
:rtype: dict
"""
sample_data = self.SAMPLE_DATA.copy()
for col_attr, col_data in self._DEFAULT_SAMPLE_DATA.items():
try:
col_num = getattr(self.COLUMN, col_attr)
except AttributeError:
continue
if col_num not in sample_data:
sample_data[col_num] = col_data
return sample_data
[docs] def setModel(self, model):
"""
After setting the model, resize the columns using the sample data and
the header data provided by the model
See Qt documentation for an explanation of arguments
"""
super(SubTabTableView, self).setModel(model)
for col_num in self.all_sample_data:
self.resizeColumnToContents(col_num)
[docs] def sizeHintForColumn(self, col_num):
"""
Provide a size hint for the specified column using the 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.all_sample_data.get(col_num, "")
width = font_metrics.horizontalAdvance(col_data) + self.MARGIN
return width
[docs] def selectionChanged(self, new_sel, old_sel):
"""
When the table selection changes, emit the appropriate signals to update
marker highlighting.
:param new_sel: The new table selection
:type new_sel: `PyQt5.QtCore.QItemSelection`
:param old_sel: The previous table selection
:type old_sel: `PyQt5.QtCore.QItemSelection`
"""
super(SubTabTableView, self).selectionChanged(new_sel, old_sel)
self._emitForSelection(old_sel, False)
self._emitForSelection(new_sel, True)
def _emitForSelection(self, sel, highlight):
"""
Emit setMarkerHighlighting for all rows in the given table selection
:param sel: The table selection
:type sel: `PyQt5.QtCore.QItemSelection`
:param highlight: Whether to highlight (True) or unhighlight (False) the
specified marker
:type highlight: bool
"""
indices = [index for index in sel.indexes() if index.column() == 0]
for index in indices:
atoms = index.data(ATOMS_ROLE)
if atoms:
self._setHighlightingForAtoms(atoms, highlight)
def _setHighlightingForAtoms(self, atoms, highlight):
"""
Emit the setMarkerHighlighting signal to change the workspace marker
highlighting for the specified atoms. Subclasses should override this
function instead of `_emitForSelection` whenever possible.
:param atoms: A list of `schrodinger.structure._StructureAtom` objects
to adjust the highlighting for
:type atoms: list
:param highlight: Whether to highlight or de-highlight the atoms
:type highlight: bool
"""
self.setMarkerHighlighting.emit(atoms, highlight)
[docs]class SubTabProxyModel(QtCore.QSortFilterProxyModel):
"""
A proxy model that filters out rows related to entry ids that are no longer
selected in the project table.
:cvar COLUMN: A class containing column constants for the table.
:vartype COLUMN: type
"""
COLUMN = None
[docs] def __init__(self, parent=None):
super(SubTabProxyModel, self).__init__(parent)
self.setSortRole(SORT_ROLE)
self._displayed_eids = []
# maintain Qt4 dynamicSortFilter default
self.setDynamicSortFilter(False)
[docs] def setSourceModel(self, model):
"""
When setting the source model, adopt its COLUMN constants if we don't
have one defined.
See Qt documentation for explanation of arguments.
"""
super(SubTabProxyModel, self).setSourceModel(model)
if self.COLUMN is None:
self.COLUMN = model.COLUMN
[docs] def setDisplayedEids(self, eids):
"""
Set the entry ids to display in the table. This should correspond to
the entry ids currently selected in the project table.
:param eids: The entry ids to display
:type eids: list
"""
self._displayed_eids = eids
self.invalidateFilter()
[docs] def isAcceptableEid(self, eid):
"""
Is the specified entry ID currently included in the Input tab table?
i.e., Would this entry ID be acceptable to display in the sub-tab table?
:param eid: The entry id
:type eid: str
:return: True if the entry ID is acceptable. False otherwise.
:rtype: bool
"""
return eid in self._displayed_eids
[docs] def filterAcceptsRow(self, source_row, source_parent=None):
"""
Accept a row only if the entry id is in `self._displayed_eids` or if
the entry id is blank
See Qt documentation for an explanation of the arguments and return
value
"""
source_model = self.sourceModel()
eid_col = source_model.COLUMN.ID
eid_index = source_model.index(source_row, eid_col)
eid = eid_index.data()
return eid in self._displayed_eids or not eid
[docs] def lessThan(self, left, right):
"""
Compare two indices for sorting. Assume that the ATOM column contains
atom names. Assume that the ATOMS column contains either:
- a string of atom names separated by commas and white space
- a list or tuple of atom names
Atom names are then sorted numerically. All other columns are sorted
using Python's less than operator (which allows tuples to be sorted as
expected).
See Qt documentation for an explanation of arguments and return value
"""
model = self.sourceModel()
sort_role = self.sortRole()
left_data = left.data(sort_role)
right_data = right.data(sort_role)
if left.column() == getattr(model.COLUMN, "ATOM", -1):
left_key = gui_utils.atom_name_sort_key(left_data)
right_key = gui_utils.atom_name_sort_key(right_data)
return left_key < right_key
elif left.column() == getattr(model.COLUMN, "ATOMS", -1):
if isinstance(left_data, str):
left_data = [atom.strip() for atom in left_data.split(",")]
right_data = [atom.strip() for atom in right_data.split(",")]
left_keys = list(map(gui_utils.atom_name_sort_key, left_data))
right_keys = list(map(gui_utils.atom_name_sort_key, right_data))
return left_keys < right_keys
else:
return left_data < right_data
[docs]class SubTabRow(object):
"""
An object representing a single row of the table. This class is intended to
be subclassed for each sub-tab. The default implementation includes entry
id and structure title.
"""
[docs] def __init__(self, entry_id=None, title=None):
self.entry_id = entry_id
self.title = title
[docs] def copy(self):
"""
Create a new row object that is a copy of this row
This method must be implemented in subclasses for use with models that
have the appendFromModel method.
:rtype: `PerAtomBasisRow`
:return: The row item that is a copy of this row
"""
raise NotImplementedError
[docs] def getAtomNums(self):
"""
Get a list of all atom numbers associated with this row (i.e. all atom
to be marked by workspace markers). The default implementation checks
for an `atom_num` or `atom_nums` attribute. Subclasses must redefine
this function if neither of these attributes exist.
:return: A list of atom numbers (relative to the entry, not the
workspace structure)
:rtype: list
"""
try:
return [self.atom_num]
except AttributeError:
return self.atom_nums
[docs] def getAtoms(self):
"""
Get a list of all atoms associated with this row (i.e. all atom to be
marked by workspace markers).
:return: A list of `schrodinger.structure._StructureAtom` objects
:rtype: list
"""
atom_nums = self.getAtomNums()
if atom_nums:
struc = self.getStructure()
return [struc.atom[i] for i in atom_nums]
else:
return []
[docs] def getStructure(self):
"""
Get the structure this row refers to
:return: The structure
:rtype: `schrodinger.structure.Structure`
"""
proj = maestro.project_table_get()
return proj[self.entry_id].getStructure()
[docs]class SubTabModel(QtCore.QAbstractTableModel):
"""
A table model for storing sub-tab data. This class is not intended to be
instantiated directly and should be subclassed. Subclasses must redefine
COLUMN, UNEDITABLE, ROW_CLASS, MARKER_SETTINGS, and _displayAndSortData.
Subclasses may also need to redefine addJaguarMarkerForRow and
removeJaguarMarkerForRow if more than one marker per row is required.
:cvar COLUMN: A class containing column constants for the table. This
class must contain:
- NUM_COLS: The number of columns in the table (int)
- HEADERS: A list of column headers (list)
- ID: The entry id column number (int)
- TITLE: The entry title column number (int) and should contain
additional integer constants for all other columns.
:vartype COLUMN: type
:cvar UNEDITABLE: A list of all column numbers that should be flagged as
uneditable
:vartype UNEDITABLE: iterable
:cvar ROW_CLASS: The `SubTabRow` subclass that represents a row of data
:vartype ROW_CLASS: type
:cvar MARKER_SETTINGS: The settings for the workspace markers. This
dictionary will be passed to `schrodinger.maestro.markers._BaseMarker.
applySettings`
:vartype MARKER_SETTINGS: dict
:cvar ERROR_BACKGROUND_BRUSH: The brush used to paint the background of
cells where the user has entered invalid data.
:vartype ERROR_BACKGROUND_BRUSH: `PyQt5.QtGui.QBrush`
:ivar addJaguarMarker: A signal emitted when a workspace marker should be
added. Emitted with:
- The list of atoms to add the marker for (list)
- The marker settings (dict)
:vartype addJaguarMarker: `PyQt5.QtCore.pyqtSignal`
:ivar removeJaguarMarker: A signal emitted when a workspace marker should be
removed. Emitted with:
- The list of atoms to remove the marker for (list)
:vartype removeJaguarMarker: `PyQt5.QtCore.pyqtSignal`
"""
COLUMN = None
UNEDITABLE = ()
ROW_CLASS = SubTabRow
MARKER_SETTINGS = {}
ERROR_BACKGROUND_BRUSH = gui_utils.ERROR_BACKGROUND_BRUSH
addJaguarMarker = QtCore.pyqtSignal(list, dict)
removeJaguarMarker = QtCore.pyqtSignal(list)
[docs] def __init__(self, parent=None):
super(SubTabModel, self).__init__(parent)
self._rows = []
[docs] def reset(self):
self.beginResetModel()
self._rows = []
self.endResetModel()
[docs] def appendFromModel(self, model):
"""
Append the rows of model to this model
:type model: subclass of `SubTabModel`
:param model: The model to copy data from, should be the same subclass
as this object
"""
for row in model._rows:
self._addRow(row.copy())
[docs] def columnCount(self, parent=None):
return self.COLUMN.NUM_COLS
[docs] def rowCount(self, parent=None):
return len(self._rows)
[docs] def updateEntryTitles(self, eids_to_titles):
"""
Update the entry titles in case they have changed in the project table
:param eids_to_titles: A dictionary of {entry id: title}
:type eids_to_titles: dict
"""
for cur_row in self._rows:
eid = cur_row.entry_id
if eid in eids_to_titles:
cur_row.title = eids_to_titles[eid]
[docs] def addRow(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._addRow(row)
def _addRow(self, row):
"""
Add the given row object to the table.
:type row: ROW_CLASS
:param row: The row to add
:return: The row number of the new row
:rtype: int
"""
if not isinstance(row, self.ROW_CLASS):
raise ValueError('row must be an instance of %s' %
str(type(self.ROW_CLASS)))
num_rows = len(self._rows)
self.beginInsertRows(QtCore.QModelIndex(), num_rows, num_rows)
self._rows.append(row)
self.endInsertRows()
self.addJaguarMarkerForRow(row)
return num_rows
[docs] def removeRows(self, row, count, parent=None):
# See Qt documentation for an explanation of arguments and return value
self.beginRemoveRows(QtCore.QModelIndex(), row, row + count - 1)
for cur_row in self._rows[row:row + count]:
self.removeJaguarMarkerForRow(cur_row)
del self._rows[row:row + count]
self.endRemoveRows()
return True
[docs] def addJaguarMarkerForRow(self, row):
"""
Add a workspace marker for the given row. Subclasses must override this
class if they require more than one workspace marker per row. Note
that any given set of atom(s) may only have one marker per sub-tab. The
sub-tab is responsible for ensuring that a marker is not added to a set
of atom(s) that already has a marker.
:param row: The row to add the marker for
:type row: `SubTabRow`
"""
atoms = row.getAtoms()
self.addJaguarMarker.emit(atoms, self.MARKER_SETTINGS)
[docs] def removeJaguarMarkerForRow(self, row):
"""
Remove the workspace marker for the given row. Subclasses must override
this class if they create more than one workspace marker per row.
:param row: The row to remove the marker for
:type row: `SubTabRow`
"""
atoms = row.getAtoms()
self.removeJaguarMarker.emit(atoms)
[docs] def rowsForEid(self, eid):
"""
Get all rows that refer to the specified entry ID
:param eid: The entry id
:type eid: str
:return: A list of `ROW_CLASS` objects
:rtype: list
"""
return [row for row in self._rows if row.entry_id == eid]
[docs] def flags(self, index):
"""
Flag columns in `UNEDITABLE` as uneditable.
See Qt documentation for an explanation of arguments and return value
"""
col = index.column()
flag = Qt.ItemIsEnabled | Qt.ItemIsSelectable
if col not in self.UNEDITABLE:
flag |= Qt.ItemIsEditable
return flag
[docs] def data(self, index, role=Qt.DisplayRole):
# See Qt documentation for an explanation of arguments and return value
col = index.column()
row_num = index.row()
row_data = self._rows[row_num]
if role in (Qt.DisplayRole, SORT_ROLE):
return self._displayAndSortData(col, row_data, role)
elif role == Qt.ToolTipRole:
return self._toolTipData(col, row_data)
elif role == Qt.BackgroundRole:
return self._backgroundData(col, row_data)
elif role in (ATOMS_ROLE, ATOM_NUMS_ROLE, STRUCTURE_ROLE):
return self._otherDataSubTab(col, row_data, role)
else:
return self._otherData(col, row_data, role)
def _displayAndSortData(self, col, row_data, role):
"""
Retrieve data for the display and sort roles
:param col: The column to return data for
:type col: int
:param row_data: The ROW_CLASS instance to retrieve data from
:type row_data: ROW_CLASS
:param role: The role to retrieve data for
:type role: int
:return: The requested data
:rtype: object
"""
if col == self.COLUMN.ID:
if role == Qt.DisplayRole:
return row_data.entry_id
else:
# Entry IDs should be sorted numerically
return int(row_data.entry_id)
elif col == self.COLUMN.TITLE:
return row_data.title
elif col == getattr(self.COLUMN, "ATOM", -1):
return row_data.atom_name
elif col == getattr(self.COLUMN, "ATOMS", -1):
if role == Qt.DisplayRole:
if row_data.atom_names:
return ", ".join(row_data.atom_names)
else:
return ""
elif role == SORT_ROLE:
return row_data.atom_names
def _backgroundData(self, col, row_data):
"""
Retrieve data for the background role
:param col: The column to return data for
:type col: int
:param row_data: The ROW_CLASS instance to retrieve data from
:type row_data: ROW_CLASS
:return: The requested data
:rtype: object
"""
# TODO: color editable columns? lightcyan?
def _toolTipData(self, col, row_data):
"""
Retrieve data for the tool tip role
:param col: The column to return data for
:type col: int
:param row_data: The ROW_CLASS instance to retrieve data from
:type row_data: ROW_CLASS
:return: The requested data
:rtype: object
"""
# This function intentionally left blank
def _otherDataSubTab(self, col, row_data, role):
"""
Retrieve data for custom roles that are defined for all sub tabs. Note
that sub-classes should redefine `_otherDataSubTab` rather than this
method.
:param col: The column to return data for
:type col: int
:param row_data: The ROW_CLASS instance to retrieve data from
:type row_data: ROW_CLASS
:param role: The role to retrieve data for
:type role: int
:return: The requested data
:rtype: object
"""
if role == ATOMS_ROLE:
return row_data.getAtoms()
elif role == ATOM_NUMS_ROLE:
return row_data.getAtomNums()
elif role == STRUCTURE_ROLE:
return row_data.getStructure()
def _otherData(self, col, row_data, role):
"""
Retrieve data for custom roles that are defined for the current sub-tab.
Note that sub-classes should redefine this method rather than
`_otherDataSubTab`.
:param col: The column to return data for
:type col: int
:param row_data: The ROW_CLASS instance to retrieve data from
:type row_data: ROW_CLASS
:param role: The role to retrieve data for
:type role: int
:return: The requested data
:rtype: object
"""
# This function intentionally left blank
[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._rows[table_row]
retval = self._setData(col, row_data, value, role, table_row)
if retval is False:
return False
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. Note that sub-classes should
redefine this method rather than `setData` whenever possible.
: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. All other values are considered
successes.
:rtype: object
"""
# This function intentionally left blank
[docs] def clearDataForEid(self, eid):
"""
Clear all data related to the specified entry ID
:param eid: The entry id
:type eid: str
"""
rev_data = reversed(list(enumerate(self._rows)))
for row_num, row_data in rev_data:
if row_data.entry_id == eid:
self.removeRow(row_num)