"""
A module for speeding up painting in Qt table and tree views. To use this
module:
- Add `SpeedUpDelegateViewMixin` to your table or tree view class. If your view
uses multiple delegates, use `MultipleSpeedUpDelegatesViewMixin` instead.
- Add `MutlipleRolesRoleModelMixin` to your `table_helper.RowBasedTableModel`
model class.
- If you have any proxies that don't modify data (i.e. proxies for sorting or
filtering), add `MultipleRolesRoleProxyPassthroughMixin` to them. If you have
any proxies that modify data, add`MultipleRolesRoleProxyMixin` to them.
- If defining custom roles, define roles using an enum that inherits from
`MultipleRolesUserRolesEnum`.
- If using custom delegates, make sure that they inherit from `SpeedUpDelegate`.
- Additionally, subclass the view's model (i.e. the top-most proxy) with
`FlagCacheProxyMixin` to cache flags() return values.
If adding any code to this file, make sure that it doesn't cause a slow down
for any other panels that make use of this module.
"""
import collections
from schrodinger.Qt import sip
from schrodinger.Qt import QtCore
from schrodinger.Qt import QtGui
from schrodinger.Qt import QtWidgets
from schrodinger.Qt.QtCore import Qt
from schrodinger.ui.qt import table_helper
# An enum that defines the role used to request data for multiple roles at once
MultipleRolesUserRolesEnum = table_helper.UserRolesEnum(
"MultipleRolesUserRolesEnum", ("MultipleRoles",))
[docs]class SpeedUpDelegate(QtWidgets.QStyledItemDelegate):
"""
A delegate that speeds up painting by:
- Requesting all data at once using the MultipleRoles role instead of
calling index.data() once per role.
- Caching all data for the most recent indices
This delegate may be instantiated directly or subclassed if custom painting
is required. If subclassing, note that data should be accessed via
option.data rather than index.data().
:cvar PAINT_ROLES: A set of all roles used in painting. Subclasses should
override this variable if they require data for additional roles.
:vartype PAINT_ROLES: frozenset
"""
PAINT_ROLES = frozenset(
(Qt.FontRole, Qt.TextAlignmentRole, Qt.ForegroundRole,
Qt.CheckStateRole, Qt.DecorationRole, Qt.DisplayRole,
Qt.BackgroundRole))
[docs] def __init__(self, parent, data_cache):
"""
:param parent: The parent widget
:type parent: `QtWidgets.QTableView`
:param data_cache: The object to use for caching model data. Note that
this cache is shared amongst all delegates and that the view, not the
delegate, is responsible for clearing the cache when the model data
changes.
:type data_cache: `DataCache`
"""
super(SpeedUpDelegate, self).__init__(parent)
self._data_cache = data_cache
self._model = None
[docs] def initStyleOption(self, option, index):
"""
Fetch all data from the index and load it into the style option
object. In addition to the standard QStyleOptionViewItem
attributes, all fetched data is stored in option.data as a
dictionary of {role: value}. This way, data that doesn't directly
affect the style options can still be accessed without needing an
additional index.data() call.
Note that the code for setting the attributes of `option` (other than
`data`) is closely based on QStyledItemDelegage::initStyleOption.
See Qt documentation for argument documentation.
"""
# Pull data from the cache when possible
hashable = (index.row(), index.column(), index.internalId())
try:
data = self._data_cache[hashable]
except KeyError:
data = self._model.data(index,
MultipleRolesUserRolesEnum.MultipleRoles,
self.PAINT_ROLES)
self._data_cache[hashable] = data
if data is None:
return
option.data = data
font_data = data.get(Qt.FontRole)
if font_data is not None:
option.font = font_data
option.fontMetrics = QtGui.QFontMetrics(font_data)
text_alignment_data = data.get(Qt.TextAlignmentRole)
if text_alignment_data is not None:
option.displayAlignment = Qt.Alignment(text_alignment_data)
foreground_data = data.get(Qt.ForegroundRole)
if foreground_data is not None:
option.palette.setBrush(QtGui.QPalette.Text, foreground_data)
check_state_data = data.get(Qt.CheckStateRole)
if check_state_data is not None:
option.features |= QtWidgets.QStyleOptionViewItem.HasCheckIndicator
option.checkState = check_state_data
decoration_data = data.get(Qt.DecorationRole)
if decoration_data is not None:
# QStyledItemDelegage::initStyleOption allows decoration data
# to be a QIcon, QColor, QImage, or QPixmap. Here, we assume
# that the data is a QIcon. This should be changed if this
# code ever needs to support a model that provides a different type.
option.features |= QtWidgets.QStyleOptionViewItem.HasDecoration
option.icon = decoration_data
option.decorationSize = decoration_data.actualSize(
option.decorationSize, QtGui.QIcon.Normal, QtGui.QIcon.On)
display_data = data.get(Qt.DisplayRole)
if display_data is not None:
# Using "(int, float)" here is measurably faster than using
# numbers.Number
if isinstance(display_data, (int, float)):
display_data = str(display_data)
try:
option.text = display_data
except TypeError:
# If we can't convert the data to a string, then ignore it.
# Maybe it's for a custom delegate that doesn't expect a string.
pass
else:
option.features |= QtWidgets.QStyleOptionViewItem.HasDisplay
background_data = data.get(Qt.BackgroundRole)
if background_data is not None:
option.backgroundBrush = background_data
[docs] def setModel(self, model):
"""
Specify the model that this delegate will be fetching data from. This
method must be called as soon as a model is set on the view. The model
is cached because call model.data() is about four times faster than
calling index.data().
:param model: The model
:type model: `QtCore.QAbstractItemModel`
"""
self._model = model
[docs]class DataCache(dict):
"""
A dictionary used for caching model data. The cache will hold data for at
most `MAXLEN` indices. When additional data is added, the oldest data will
be removed from the cache to avoid excessive memory usage.
"""
[docs] def __init__(self, maxlen=10000):
super(DataCache, self).__init__()
self._maxlen = maxlen
# a deque is about 20x faster than a list with a maxlen of 10,000
# since list.pop(0) is O(number of elements in the list), while
# deque.pop(0) is O(1)
self._queue = collections.deque()
def __setitem__(self, key, val):
# Note that setting data for a key that already exists will eventually
# lead to a traceback since we'll wind up trying to remove that key
# twice. If the cache is ever used in that way, we'll need to add an
# "if key in self" check here.
if len(self._queue) >= self._maxlen:
to_remove = self._queue.popleft()
del self[to_remove]
self._queue.append(key)
super(DataCache, self).__setitem__(key, val)
[docs] def clear(self):
self._queue.clear()
super(DataCache, self).clear()
[docs]class MultipleSpeedUpDelegatesViewMixin(object):
"""
A mixin for `QtWidgets.QAbstractItemView` subclasses that cache data
using `SpeedUpDelegate` (or a subclass) and require multiple
delegates. Subclasses are required to instantiate all required
delegates and must call setModel() on all delegates from
view.setModel().
If only a single delegate is required, see `SpeedUpDelegateViewMixin`
below.
:cvar DATA_CACHE_SIZE: The maximum length of the `DataCache` cache.
:vartype DATA_CACHE_SIZE: int
"""
DATA_CACHE_SIZE = 10000
[docs] def __init__(self, *args, **kwargs):
super(MultipleSpeedUpDelegatesViewMixin, self).__init__(*args, **kwargs)
self._data_cache = DataCache(self.DATA_CACHE_SIZE)
[docs] def setModel(self, model):
"""
Connect signals so that the cache is cleared whenever it contains
stale data. This needs to be done before anything else, so we
connect these signals before calling the super-class setModel().
See QAbstractItemView documentation for additional method documentation.
"""
signals = (model.rowsInserted, model.rowsRemoved, model.rowsMoved,
model.columnsInserted, model.columnsRemoved,
model.columnsMoved, model.modelReset, model.layoutChanged,
model.dataChanged)
for cur_signal in signals:
cur_signal.connect(self._data_cache.clear)
super(MultipleSpeedUpDelegatesViewMixin, self).setModel(model)
[docs]class SpeedUpDelegateViewMixin(MultipleSpeedUpDelegatesViewMixin):
"""
A mixin for `QtWidgets.QAbstractItemView` subclasses that cache data
using `SpeedUpDelegate` (or a subclass) and use a single delegate for
the entire table. If multiple delegates are required, see
`MultipleSpeedUpDelegateViewMixin` above.
:cvar DELEGATE_CLASS: The class of the delegate for the table.
Subclasses may override this, but the provided class must be a subclass
of `SpeedUpDelegate`.
:vartype DELEGATE_CLASS: type
"""
DELEGATE_CLASS = SpeedUpDelegate
[docs] def __init__(self, *args, **kwargs):
super(SpeedUpDelegateViewMixin, self).__init__(*args, **kwargs)
self._delegate = self.DELEGATE_CLASS(self, self._data_cache)
self.setItemDelegate(self._delegate)
[docs] def setModel(self, model):
self._delegate.setModel(model)
super(SpeedUpDelegateViewMixin, self).setModel(model)
[docs]class MultipleRolesRoleModelMixin(table_helper.DataMethodDecoratorMixin):
"""
A mixin for models that can provide data for multiple roles at once
with the `MultipleRolesUserRolesEnum.MultipleRoles` role. This mixin
is intended for use with `table_helper.RowBasedTableModel` subclasses,
but may be used with any `QAbstractItemModel` subclass that defines
`_genDataArgs`,
"""
[docs] def data(self, index, role=Qt.DisplayRole, multiple_roles=None):
"""
Provide data for the specified index and role. Subclasses normally
do not need to redefine this method. Instead, new methods should
be created and decorated with `table_helper.data_method`.
:param index: The index to return data for.
:type index: `QtCore.QModelIndex`
:param role: The role to request data for.
:type role: int
:param multiple_roles: If `role` equals
{MultipleRolesUserRolesEnum.MultipleRoles}, a set of roles to
retrieve data for. Ignored otherwise.
:type multiple_roles: frozenset
:return: The requested data. If `role` equals
{MultipleRolesUserRolesEnum.MultipleRoles}, will be a dictionary of
{role: value}. The dictionary not contain roles that are not
provided by this model and may contain additional roles that were
not explicitly requested.
:rtype: object
"""
if role not in self._data_methods or not index.isValid():
if role == MultipleRolesUserRolesEnum.MultipleRoles:
return {}
else:
return None
data_args = self._genDataArgs(index)
if role == MultipleRolesUserRolesEnum.MultipleRoles:
data_args.append(multiple_roles)
else:
data_args.append(role)
return self._callDataMethod(role, data_args)
@table_helper.data_method(MultipleRolesUserRolesEnum.MultipleRoles)
def _multipleRolesData(self, *args):
"""
Provide data for all requested roles. The last argument must be an
iterable of roles to fetch data for. All additional arguments will
be passed to the data methods.
:return: A {role: value} dictionary of data for all requested roles.
:rtype: dict
"""
data = {}
multiple_roles = args[-1]
args = args[:-1]
self._fetchMultipleRoles(data, multiple_roles, *args)
return data
def _fetchMultipleRoles(self, data, roles, *args):
"""
Add data for all specified roles to the `data` dictionary. Note
that roles that are not provided by this model will be ignored.
:param data: The dictionary to add data to.
:type data: dict
:param roles: An iterable of roles to add data for
:type roles: iterable
All additional arguments will be passed to the data methods.
"""
for cur_role in roles:
if cur_role not in self._data_methods:
continue
cur_args = args + (cur_role,)
data[cur_role] = self._callDataMethod(cur_role, cur_args)
[docs]class MultipleRolesRoleProxyMixin(MultipleRolesRoleModelMixin):
"""
A mixin for proxy models that can provide data for multiple roles at
once with the `MultipleRolesUserRolesEnum.MultipleRoles` role. This
mixin is only intended for proxies that provide or modify data. For
proxies that sort or filter without modifying data, use
`MultipleRolesRoleProxyPassthroughMixin` instead.
"""
[docs] def data(self, proxy_index, role=Qt.DisplayRole, multiple_roles=None):
# See parent class for method documentation
if not proxy_index.isValid():
if role == MultipleRolesUserRolesEnum.MultipleRoles:
return {}
else:
return None
source_index = self.mapToSource(proxy_index)
source_model = self.sourceModel()
if role not in self._data_methods:
return source_model.data(source_index, role)
data_args = [proxy_index, source_index, source_model]
data_args.extend(
self._genDataArgs(proxy_index, source_index, source_model))
if role == MultipleRolesUserRolesEnum.MultipleRoles:
data_args.append(multiple_roles)
else:
# add None as a place holder for the data dictionary that
# _multipleRolesData would pass to data methods
data_args.extend((None, role))
return self._callDataMethod(role, data_args)
def _genDataArgs(self, proxy_index, source_index, source_model):
"""
Return any arguments that should be passed to the data methods.
Note that the proxy index, source index, and source model (i.e. the
arguments to this method) will always be passed to data methods as
the first three arguments regardless of the list returned from this
method. Subclasses may redefine this method to return any
additional required arguments. 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`
:param source_model: The source model. Provided because calling
`model.data(index)` is much faster than calling `index.data()`.
:type source_model: `QtCore.QAbstractItemModel`
:return: A list of 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 []
@table_helper.data_method(MultipleRolesUserRolesEnum.MultipleRoles)
def _multipleRolesData(self, proxy_index, source_index, source_model,
*args):
# See parent class for method documentation
multiple_roles = args[-1]
data = source_model.data(source_index,
MultipleRolesUserRolesEnum.MultipleRoles,
multiple_roles)
# replace the multiple_roles argument with the data dictionary
args[-1] = data
self._fetchMultipleRoles(data, multiple_roles, proxy_index,
source_index, source_model, *args)
return data
[docs]class MultipleRolesRoleProxyPassthroughMixin(object):
"""
A mixin for proxy models that sort or filter a
`MultipleRolesRoleModelMixin` model but don't provide or modify any
data. For proxies that modify or provide data, use
`MultipleRolesRoleProxyMixin` instead.
"""
[docs] def data(self, proxy_index, role, multiple_roles=None):
# See MultipleRolesRoleModelMixin for method documentation
source_index = self.mapToSource(proxy_index)
if source_index.isValid():
return self.sourceModel().data(source_index, role, multiple_roles)
elif role == MultipleRolesUserRolesEnum.MultipleRoles:
return {}
else:
return None
[docs]class AbstractFlagCacheProxyMixin(object,
metaclass=AbstractFlagCacheProxyMixinMetaclass
):
"""
A mixin for `QAbstractItemProxyModel` subclasses to cache flags() return
values. This class does not implement any caching and should not be used
directly. See `FlagCacheProxyMixin` below instead.
Note that if this mixin is used on a non-proxy model - or on a proxy model
that changes flags() return values independently of changes to the
underlying source model - then the subclass is responsible for calling
`self._flag_cache.clear()` whenever the flags() return value changes.
"""
[docs] def __init__(self, *args, **kwargs):
self._flag_cache = {}
super(AbstractFlagCacheProxyMixin, self).__init__(*args, **kwargs)
[docs] def setSourceModel(self, model):
"""
When this class is mixed in to a proxy model, connect signals so that
the cache is cleared whenever it contains stale data. This needs to be
done before anything else, so we connect these signals before calling
the super-class setSourceModel().
See QAbstractItemProxyModel documentation for additional method
documentation.
"""
# Make sure that the cache is cleared whenever it contains stale data.
# This needs to be done before anything else, so we connect these
# signals before calling the super-class setModel().
signals = (model.rowsInserted, model.rowsRemoved, model.rowsMoved,
model.columnsInserted, model.columnsRemoved,
model.columnsMoved, model.modelReset, model.layoutChanged,
model.dataChanged)
for cur_signal in signals:
cur_signal.connect(self._flag_cache.clear)
super(AbstractFlagCacheProxyMixin, self).setSourceModel(model)
[docs]class FlagCacheProxyMixin(AbstractFlagCacheProxyMixin):
"""
A mixin for `QAbstractItemProxyModel` subclasses to cache flags() return
values per-cell.
"""
[docs] def flags(self, index):
# See QAbstractItemProxyModel documentation for additional method
# documentation.
index_hashable = index.row(), index.column(), index.internalId()
try:
return self._flag_cache[index_hashable]
except KeyError:
flag = super(FlagCacheProxyMixin, self).flags(index)
self._flag_cache[index_hashable] = flag
return flag