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

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


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

    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

    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

    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


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

    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)

    def clear(self):
        self._queue.clear()
        super(DataCache, self).clear()


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

    def __init__(self, *args, **kwargs):
        super(MultipleSpeedUpDelegatesViewMixin, self).__init__(*args, **kwargs)
        self._data_cache = DataCache(self.DATA_CACHE_SIZE)

    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)


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

    def __init__(self, *args, **kwargs):
        super(SpeedUpDelegateViewMixin, self).__init__(*args, **kwargs)
        self._delegate = self.DELEGATE_CLASS(self, self._data_cache)
        self.setItemDelegate(self._delegate)

    def setModel(self, model):
        self._delegate.setModel(model)
        super(SpeedUpDelegateViewMixin, self).setModel(model)


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`,
    """

    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)


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

    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


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

    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


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

    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


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

    def __init__(self, *args, **kwargs):
        self._flag_cache = {}
        super(AbstractFlagCacheProxyMixin, self).__init__(*args, **kwargs)

    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)


class FlagCacheProxyMixin(AbstractFlagCacheProxyMixin):
    """
    A mixin for `QAbstractItemProxyModel` subclasses to cache flags() return
    values per-cell.
    """

    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