Source code for schrodinger.ui.qt.table_helper

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


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


@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


class ModelResetContextMixin:
    """
    A mixin for `QtCore.QAbstractItemModel` subclasses that adds a
    `modelResetContext` context manager to reset the model.
    """

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


class DataMethodDecoratorMixin(object):
    """
    A mixin for `QtCore.QAbstractItemModel` subclasses that use the
    `data_method` mixin.  Subclasses must define `_genDataArgs`.
    """

    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

    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


class DataMethodDecoratorProxyMixin(DataMethodDecoratorMixin):
    """
    A mixin for `QtCore.QAbstractProxyModel` subclasses that use the
    `data_method` mixin.
    """

    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]


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

    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)

    @model_reset_method
    def reset(self):
        """
        Remove all data from the model
        """

        self._rows = []

    def columnCount(self, parent=None):
        # See Qt documentation for method documentation
        return len(self.Column)

    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

    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)

    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

    @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

    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)

    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

    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

    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)

    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)

    def headerData(self, section, orientation, role=Qt.DisplayRole):
        """
        Provide column headers, and optionally column tooltips and row numbers.

        See Qt documentation for an explanation of arguments and return value
        """

        if orientation == Qt.Horizontal:
            if role == Qt.DisplayRole:
                return self.Column(section).title
            elif role == Qt.ToolTipRole:
                return self.Column(section).tooltip
        else:
            if role == Qt.DisplayRole and self.SHOW_ROW_NUMBERS:
                return section + 1

    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

    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]

    def formatFloat(self, value, role, digits, fmt=""):
        """
        Format floating point values for display or sorting.  If `role` is
        `Qt.DisplayRole`, then `value` will be returned as a string with the
        specified formatting.  All other `role` values are assumed to be a
        sorting role and value will be returned unchanged.

        :param value: The floating point value to format
        :type value: float

        :param role: The Qt data role
        :type role: int

        :param digits: The number of digits to include after the decimal point
            for Qt.DisplayRole
        :type digits: int

        :param fmt: Additional floating point formatting options
        :type fmt: str

        :return: The formatted or unmodified value
        :rtype: str or float
        """

        if role == Qt.DisplayRole:
            return "{0:{2}.{1}f}".format(value, digits, fmt)
        else:
            return value

    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

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

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


class RowBasedTableModel(RowBasedModelMixin, QtCore.QAbstractTableModel):
    pass


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

    def columnCount(self, parent=None):
        # See Qt documentation for method documentation
        return 1

    def headerData(self, section, orientation, role=Qt.DisplayRole):
        # See Qt documentation for method documentation
        return None

    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


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

    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)

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


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

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

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

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

    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.width(col_data) + self.MARGIN
        return width

    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


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


class UserRolesEnumMeta(enum.EnumMeta):
    """
    The metaclass for `UserRolesEnum`.  See `UserRolesEnum` for documentation.
    """

    def __new__(metacls, cls, bases, classdict, offset=None, step_size=None):
        offset, step_size = metacls._getOffsetAndStepSize(
            bases, offset, step_size)
        class_obj = super().__new__(metacls, cls, bases, classdict)
        class_obj._OFFSET = offset
        class_obj._STEP_SIZE = step_size
        return class_obj

    @classmethod
    def __prepare__(metacls, cls, bases, offset=None, step_size=None):
        offset, step_size = metacls._getOffsetAndStepSize(
            bases, offset, step_size)
        classdict = _UserRolesEnumDict(step_size)
        metacls._inheritRoles(bases, classdict)
        classdict.setCurrentValue(offset)
        return classdict

    @classmethod
    def _getOffsetAndStepSize(metacls, bases, offset=None, step_size=None):
        """
        Get the appropriate values for offset and step size for the
        UserRolesEnum subclass being created.  These values are used to
        auto-number roles.

        :param bases: The classes that the class being created inherits from.
        :type bases: tuple

        :param offset: The value for the first auto-numbered role.  Note that
            Qt.UserRole is added to this number as all user created roles must
            be greater than or equal to that.  Defaults to 0.
        :type offset: int

        :param step_size: The interval between auto-numbered role numbers.
            Defaults to 1.
        :type step_size: int

        :raise ValueError: If step size is 0.
        """
        if offset is None:
            offset = metacls._getInheritedValue("_OFFSET", 0, bases)
        if step_size is None:
            step_size = metacls._getInheritedValue("_STEP_SIZE", 1, bases)
        if step_size == 0:
            raise ValueError("Cannot use a step size of 0")
        return offset, step_size

    @staticmethod
    def _getInheritedValue(name, default, bases):
        """
        Determine the appropriate value for attribute `name` in the class being
        created.

        :param name: The attribute name.
        :type name: str

        :param default: The default value to use if no value has been specified
            or inherited.
        :type default: object

        :param bases: The classes that the class being created inherits from.
        :type bases: tuple

        :param classdict: The dictionary of attributes for the class being
            created.
        :type classdict: dict
        """

        for cur_base in bases:
            val = getattr(cur_base, name, None)
            if val is not None:
                return val
        return default

    @staticmethod
    def _inheritRoles(bases, classdict):
        """
        Determine if there are any enum members that the class being created
        should inherit.

        :param bases: The classes that the class being created inherits from.
        :type bases: tuple

        :param classdict: The dictionary of attributes for the class being
            created.
        :type classdict: dict
        """

        if UserRolesEnum is None:
            # We can't check if this class is a grandchild of UserRolesEnum if
            # we're in the process of defining UserRolesEnum
            return
        for cur_base in bases:
            if issubclass(cur_base, UserRolesEnum) and cur_base._member_names_:
                for cur_role in cur_base:
                    if cur_role.name not in classdict:
                        classdict[cur_role.name] = cur_role.value

    def __call__(cls,
                 value,
                 names=None,
                 module=None,
                 type=None,
                 offset=None,
                 step_size=None):
        if names is None:
            return super(UserRolesEnumMeta, cls).__call__(
                value, names, module, type)
        if isinstance(names, str):
            names = names.replace(',', ' ').split()
        if offset is None:
            offset = getattr(cls, "_OFFSET", 0)
        if step_size is None:
            step_size = getattr(cls, "_STEP_SIZE", 1)
        if step_size == 0:
            raise ValueError("Cannot use a step size of 0")

        new_names = []
        existing_values = {role.value for role in cls}
        i = 0
        for cur_name in names:
            while True:
                role_num = i * step_size + Qt.UserRole + offset
                i += 1
                if role_num not in existing_values:
                    break
            new_names.append((cur_name, role_num))
        class_obj = super(UserRolesEnumMeta, cls).__call__(
            value, new_names, module=module, type=type)
        class_obj._OFFSET = offset
        class_obj._STEP_SIZE = step_size
        return class_obj

    @staticmethod
    def _get_mixins_(bases):
        # See parent class for method documentation.  This method is overridden
        # here so that UserRolesEnum classes can inherit enum members
        return int, bases[-1]


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


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

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


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)


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

    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)

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

    @maestro_callback.project_close
    def onProjectClosed(self):
        """
        Reset the table when a project is closed to avoid invalid data.
        """
        self.reset()
        self._pt = None

    @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

    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)

    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)