Source code for schrodinger.ui.qt.table_speed_up

"""
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 AbstractFlagCacheProxyMixinMetaclass(sip.wrappertype): """ A metaclass for `FlagCacheProxyMixin`. It ensures, in the following scenario:: class MyModel(FlagCacheProxyMixin, QtCore.QAbstractProxyModel): def flags(self, index): if self.longComplicatedThing(index): return Qt.ItemIsSelectable | Qt.ItemIsEditable else: return Qt.NoItemFlags my_model = MyModel() that `my_model.flags(index)` will call `FlagCacheProxyMixin.flags` first, and that `MyModel.flags` is only called if the desired value is not found in the cache. Without this metaclass, `MyModel.flags` would instead be called first and `my_model` would never use the flags cache. """
[docs] def mro(cls): """ Determine the method resolution order for the specified class. If this class inherits from `QtCore.QAbstractItemModel`, then we make sure that the appropriate `AbstractFlagCacheProxyMixin` subclass appears first in the MRO list. :param cls: The class to determine the MRO for :type cls: `AbstractFlagCacheProxyMixin` :return: A list of base class in desired MRO order :rtype: list(object) """ mro = super(AbstractFlagCacheProxyMixinMetaclass, cls).mro() # If this class doesn't inherit from QtCore.QAbstractItemModel then # we're just creating a new mixin class and we don't need to modify the # mro if any( issubclass(base, QtCore.QAbstractItemModel) for base in cls.__bases__): # Find the first mixin class in the mro and move it to the front for i, base in enumerate(mro): if (issubclass(base, AbstractFlagCacheProxyMixin) and not issubclass(base, QtCore.QAbstractItemModel)): mro.pop(i) mro.insert(0, base) break return mro
[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