Source code for schrodinger.application.msv.gui.popups
import sys
from functools import partial
import schrodinger
from schrodinger.application.msv import utils
from schrodinger.application.msv.dependencies import INSTALLED_DEPENDENCIES
from schrodinger.application.msv.dependencies import Dependency
from schrodinger.application.msv.dependencies import is_prime_installed
from schrodinger.application.msv.gui import color_widgets
from schrodinger.application.msv.gui import dialogs
from schrodinger.application.msv.gui import gui_models
from schrodinger.application.msv.gui import stylesheets
from schrodinger.application.msv.gui import viewconstants
from schrodinger.application.msv.gui.color import AbstractResiduePropertyScheme
from schrodinger.infra import util as infra_util
from schrodinger.models import mappers
from schrodinger.protein import annotation
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 messagebox
from schrodinger.ui.qt import pop_up_widgets
from schrodinger.ui.qt import utils as qt_utils
from schrodinger.ui.qt import widgetmixins
from schrodinger.ui.qt.mapperwidgets import EnumComboBox
from schrodinger.ui.qt.mapperwidgets import MappableComboBox
from schrodinger.ui.qt.multi_combo_box import MultiComboBox
from schrodinger.ui.qt.standard_widgets import hyperlink
from schrodinger.ui.qt.utils import suppress_signals
maestro = schrodinger.get_maestro()
SEQ_ANNO = annotation.ProteinSequenceAnnotations.ANNOTATION_TYPES
ALN_ANNO = annotation.ProteinAlignmentAnnotations.ANNOTATION_TYPES
ANNO_DEPENDENCIES = {
    SEQ_ANNO.antibody_cdr: set(
        [Dependency.Prime, Dependency.Clustal, Dependency.Bioluminate])
}
ICONS_PATH = ':/msv/icons/'
LAYOUT_ROW_PADDING = 6
SEPARATOR_COLOR = QtGui.QColor(210, 210, 210)  # Light gray
HAS_WORKSPACE = bool(maestro)
COLOR_SEQ_ALN_ITEMS = {
    "All residues on tab": viewconstants.ColorByAln.All,
    viewconstants.MATCHING_RESIDUES_ONLY: viewconstants.ColorByAln.Matching,
    viewconstants.DIFFERENT_RESIDUES_ONLY: viewconstants.ColorByAln.Different,
    "Residues matching consensus": viewconstants.ColorByAln.MatchingCons,
    "Residues differing from consensus": viewconstants.ColorByAln.DifferentCons,
    "No residues on tab": viewconstants.ColorByAln.Unset,
}  # yapf: disable
COLOR_SEQ_ALN_TOOLTIPS = {
    viewconstants.ColorByAln.All: "Color all residues on this tab",
    viewconstants.ColorByAln.Matching: "Color only residues matching the reference sequence",
    viewconstants.ColorByAln.Different: "Color only residues differing from the reference sequence",
    viewconstants.ColorByAln.MatchingCons: "Color only residues matching the consensus",
    viewconstants.ColorByAln.DifferentCons: "Color only residues differing from the consesus",
    viewconstants.ColorByAln.Unset: "Don't color residues on this tab"
} # yapf: disable
ANTIBODY_CDR_ITEMS = annotation.ANTIBODY_CDR_ITEMS
RESIDUE_PROPENSITY_ITEMS = {
    "Helix Propensity": SEQ_ANNO.helix_propensity,
    "Strand Propensity": SEQ_ANNO.beta_strand_propensity,
    "Turn Propensity": SEQ_ANNO.turn_propensity,
    "Helix Termination": SEQ_ANNO.helix_termination_tendency,
    "Exposure Tendency": SEQ_ANNO.exposure_tendency,
    "Steric Group": SEQ_ANNO.steric_group,
    "Side-Chain Chemistry": SEQ_ANNO.side_chain_chem,
}
ANTIBODY_CDR_TOOLTIPS = [
    "Use the %s numbering scheme for CDRs" % name
    for name in list(ANTIBODY_CDR_ITEMS)
]
HIGHLIGHT_COLORS = dict(red=(233, 65, 48),
                        red_orange=(234, 105, 53),
                        orange=(238, 154, 60),
                        yellow=(255, 253, 84),
                        yellow_green=(171, 250, 79),
                        green=(93, 200, 60),
                        green_blue=(88, 197, 198),
                        blue=(66, 145, 243),
                        indigo=(30, 44, 241),
                        purple=(137, 50, 242),
                        violet=(188, 58, 242),
                        pink=(230, 63, 243),
                        pink_red=(228, 63, 142))
OUTLINE_COLORS = {
    color: HIGHLIGHT_COLORS[color]
    for color in ['red', 'orange', 'green', 'indigo', 'purple', 'pink']
}
[docs]class ClickableLabel(QtWidgets.QLabel):
    """
    A label that emits a 'clicked' signal when clicked
    :ivar clicked: emitted when the lable is clicked,
    :vartype clicked: `QtCore.pyqtSignal`
    """
    clicked = QtCore.pyqtSignal()
[docs]    def mousePressEvent(self, event):
        QtWidgets.QLabel.mousePressEvent(self, event)
        self.clicked.emit()
[docs]class DarkBackgroundLabel(ClickableLabel):
    """
    A label that has dark background and text in white
    """
[docs]    def __init__(self, text, parent=None, style=stylesheets.DARK_BG_STYLE):
        """
        :param text: Label text
        :type text: string
        """
        super().__init__(text, parent)
        self.setStyleSheet(style)
[docs]class GrayLabel(QtWidgets.QLabel):
    """
    A label with smaller font and gray text, white background
    """
[docs]    def __init__(self, text, parent=None):
        """
        :param text: Label text
        :type text: string
        """
        super().__init__(text, parent)
        self.setStyleSheet(stylesheets.GRAY_LABEL_STYLE)
[docs]class StyledButton(QtWidgets.QPushButton):
[docs]    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self.setStyleSheet(stylesheets.BUTTON_STYLE)
[docs]class PlusMinusWidget(mappers.TargetMixin, QtWidgets.QFrame):
    """
    A widget that uses two labels that have plus or minus on them, to change
    the value attribute.
    """
    LAYOUT_SPACING = 5
[docs]    def __init__(self, parent=None):
        super().__init__(parent)
        self.setStyleSheet(stylesheets.PLUS_MINUS_WIDGET)
        self.value = 0
        self.plus = ClickableLabel("+", self)
        self.plus.setObjectName('plus_minus_lbl')
        minus = "\u2212"  # unicode for "−"
        self.minus = ClickableLabel(minus, self)
        self.minus.setObjectName('plus_minus_lbl')
        self.plus.clicked.connect(self.increaseFont)
        self.minus.clicked.connect(self.decreaseFont)
        layout = QtWidgets.QHBoxLayout(self)
        layout.addWidget(GrayLabel("Size:", self))
        layout.addSpacing(self.LAYOUT_SPACING)
        layout.addWidget(self.minus)
        layout.addWidget(self.plus)
        layout.setSpacing(self.LAYOUT_SPACING)
[docs]    def increaseFont(self):
        if self.value < viewconstants.MAX_TEXT_SIZE:
            self.value += 1
            self.targetValueChanged.emit()
[docs]    def decreaseFont(self):
        if self.value > viewconstants.MIN_TEXT_SIZE:
            self.value -= 1
            self.targetValueChanged.emit()
[docs]class IconLabelToggle(QtWidgets.QCheckBox):
    """
    Customized checkbox with an image icon on the left, which
    changes upon clicking.
    """
    # TODO: Make this a QFrame widget with a label and a checkbox inside of it
    # instead of a checkbox with an icon. This will prevent the icon from
    # getting chopped off
[docs]    def __init__(self, text, checked=False, parent=None):
        """
        :param text: Text appear to the right of the icon
        :type text: string
        :param checked: default state of the toggle
        :type checked: bool
        """
        super().__init__(text, parent)
        self.setStyleSheet(stylesheets.ICON_LABEL_TOGGLE)
        self.setChecked(checked)
[docs]class ToggleOption(QtCore.QObject):
    """
    An instance handles one side of a SideBySideToggle
    """
    clicked = QtCore.pyqtSignal(object)
[docs]    def __init__(self,
                 text,
                 value,
                 font,
                 parent=None,
                 is_terminal_opt=True,
                 tooltip=None):
        """
        :param text: The textual representation of the toggle option's value
        :type text: str
        :param value: The value represented by the toggle option
        :type value: object
        :param font: The font object to use to calculate label dimensions
        :type font: `QtGui.QFont`
        :param is_terminal_opt: Whether the option passed in is the last in the
                                group
        :type is_terminal_opt: bool
        :param tooltip: An optional tooltip
        :type tooltip: None or str
        """
        super().__init__(parent)
        if is_terminal_opt:
            self.selected_style = stylesheets.SELECTED_LABEL_STYLE
            self.unselected_style = stylesheets.UNSELECTED_LABEL_STYLE
        else:
            self.selected_style = stylesheets.SELECTED_CENTER_LABEL_STYLE
            self.unselected_style = stylesheets.UNSELECTED_CENTER_LABEL_STYLE
        self.text = text
        self.value = value
        self.font = font
        self.label = self._makeLabel(text)
        if tooltip is not None:
            self.label.setToolTip(tooltip)
        self.label.clicked.connect(self.onLabelClicked)
    def _makeLabel(self, text):
        """
        Makes a label with the specified text
        :param text: The text to set on the label
        :type text: str
        :rtype: ClickableLabel
        :return: A label with the appropriate text
        """
        return ClickableLabel(text)
[docs]    def select(self, selected=True):
        """
        Changes the label's stylesheet to indicate whether it is selected
        :param selected: Whether to select the label
        :type selected: bool
        """
        stylesheet = self.selected_style if selected else self.unselected_style
        self.label.setStyleSheet(stylesheet)
[docs]    def onLabelClicked(self):
        """
        Emits value when the label is clicked
        """
        self.clicked.emit(self.value)
[docs]class BlankToggleOption(ToggleOption):
    """
    Toggle option that displays an X to indicate that it is blank
    """
    EXTRA_STYLESHEET = """
    ClickableLabel { padding: 0px; }
    """
    SIZE = 24
[docs]    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self.selected_style += self.EXTRA_STYLESHEET
        self.unselected_style += self.EXTRA_STYLESHEET
        off_pixmap = QtGui.QPixmap(":/msv/icons/no_text.png")
        self._off_pixmap = off_pixmap.scaled(self.SIZE, self.SIZE)
        on_pixmap = QtGui.QPixmap(":/msv/icons/no_text-ON.png")
        self._on_pixmap = on_pixmap.scaled(self.SIZE, self.SIZE)
        self.label.setPixmap(self._off_pixmap)
[docs]    def select(self, selected=True):
        super().select(selected)
        pixmap = self._on_pixmap if selected else self._off_pixmap
        self.label.setPixmap(pixmap)
[docs]class SideBySideToggle(mappers.TargetMixin, QtWidgets.QWidget):
    """
    A customized widget for switching between text options representing
    different values
    Emits a signal with the appropriate value when a new option is clicked.
    Changes background color when an option is selected
    """
    LAYOUT_SPACE = 10
[docs]    def __init__(self,
                 label_text,
                 text_value_mapping,
                 initial_value,
                 parent=None,
                 margins=True,
                 tooltips=None):
        """
        :param label_text: Text that appears to the left of the options
        :type label_text: str
        :param text_value_mapping: An ordered mapping between the label texts
            presented to the user and the underlying values they represent
        :type text_value_mapping: list of tuples
        :param initial_value: The initial value for the widget
        :type initial_value: object
        :param margins: Whether the layout for this widget should have non-zero
            margins.
        :type margins: bool
        :param tooltips: An optional mapping of labels to tooltips
        :type tooltips: dict
        :raise ValueError: If the values in the map are not distinct
        """
        super().__init__(parent)
        self._selected = None
        layout = QtWidgets.QHBoxLayout(self)
        if not margins:
            layout.setContentsMargins(0, 0, 0, 0)
        label = GrayLabel(label_text)
        layout.addWidget(label)
        layout.addSpacing(self.LAYOUT_SPACE)
        self._value_option_toggle_mapping = {}
        layout.setSpacing(0)
        tooltips = {} if tooltips is None else tooltips
        for idx, map_item in enumerate(text_value_mapping):
            text, value = map_item
            is_terminal_opt = idx == 0 or idx == len(text_value_mapping) - 1
            ToggleCls = ToggleOption if text.strip() else BlankToggleOption
            tooltip = tooltips.get(text, None)
            option = ToggleCls(text,
                               value,
                               self.font(),
                               self,
                               is_terminal_opt=is_terminal_opt,
                               tooltip=tooltip)
            layout.addWidget(option.label)
            option.clicked.connect(self.setValue)
            self._value_option_toggle_mapping[value] = option
        self.setValue(initial_value)
[docs]    def labels(self):
        """
        :rtype: list
        :return: The labels used in the side by side toggle widget
        This is useful for testing.
        """
        return [
            option.label
            for option in self._value_option_toggle_mapping.values()
        ]
[docs]    def setValue(self, value):
        """
        Sets the value for the widget, which will select the associated label
        and deselect the others.
        :param value: Any Python object that is among the possible values for
                      the widget
        :type value: object
        :raise ValueError: If the value isn't among the possible values defined
                           on instantiation of the widget
        """
        try:
            selected_option = self._value_option_toggle_mapping[value]
        except KeyError:
            msg = "%s is not a possible value for this widget" % str(value)
            raise ValueError(msg)
        if self._selected == selected_option:
            # Don't bother to do anything if that option is already selected
            return
        self._selected = selected_option
        for option in self._value_option_toggle_mapping.values():
            option.select(option == selected_option)
        self.targetValueChanged.emit()
[docs]    def getValue(self):
        """
        Returns the selected value for the widget
        :rtype: object
        :return: The selected value for the widget
        """
        return self._selected.value
[docs]class MsvComboBox(MappableComboBox):
[docs]    def __init__(self, parent=None):
        super().__init__(parent)
        self.setStyleSheet(stylesheets.LIGHT_COMBOBOX)
        # MSV-1962: Workaround for a bug where the combobox background color
        # was being applied to the selected item background on Linux
        self.setItemDelegate(MsvComboBoxDelegate(self))
[docs]    def setTooltips(self, tooltips):
        """
        Sets tooltips for each item in the ComboBox as defined by a list.
        :param tooltips: Messages to display as tooltips.
        :type tooltips: list of str
        """
        for i, tooltip in enumerate(tooltips):
            self.setItemData(i, tooltip, Qt.ToolTipRole)
[docs]class MsvComboBoxDelegate(QtWidgets.QStyledItemDelegate):
    """
    Delegate for use with MsvComboBox that fixes bugs on Linux with
    background coloring (MSV-1962) and explicitly draws separators (MSV-2550)
    """
[docs]    def paint(self, painter, option, index):
        """
        It is necessary to explicitly implement separator painting when using a
        QStyledItemDelegate on a QComboBox
        """
        if self.isSeparator(index):
            self.drawSeparator(painter, option)
        else:
            super().paint(painter, option, index)
[docs]    def sizeHint(self, option, index):
        """
        Half the row height of separator rows so there isnt too much padding
        """
        size = super().sizeHint(option, index)
        if self.isSeparator(index):
            height = int(size.height() / 2)
            size.setHeight(height)
        return size
[docs]    def drawSeparator(self, painter, option):
        painter.setPen(QtGui.QPen(SEPARATOR_COLOR))
        painter.drawLine(option.rect.left() + 3,
                         option.rect.center().y(),
                         option.rect.right() - 3,
                         option.rect.center().y())
[docs]class BindingSiteDistanceComboBox(EnumComboBox):
[docs]    def __init__(self, parent=None):
        super().__init__(enum=annotation.BindingSiteDistance, parent=parent)
        self.setStyleSheet(stylesheets.LIGHT_COMBOBOX)
        # MSV-1962: Workaround for a bug where the combobox background color
        # was being applied to the selected item background on Linux
        self.setItemDelegate(MsvComboBoxDelegate(self))
        display_texts = [(enum_member, f"{enum_member.value}Å")
                         for enum_member in self._listified_enum]
        self.updateItemTexts(display_texts)
[docs]    def setEnabled(self, enable):
        super().setEnabled(enable)
        self.setVisible(enable)
        self.targetValueChanged.emit()
[docs]class MappableMultiComboBox(mappers.TargetMixin, MultiComboBox):
    """
    MultiComboBox that can be mapped to a SetParam representing the checked values.
    It must be passed a dictionary of all possible values in the SetParam,
    keyed by display name.
    """
[docs]    def __init__(self, parent=None, itemdict=None):
        super().__init__(parent=parent)
        if itemdict is None:
            raise TypeError("itemdict must be provided")
        self.addItems(itemdict)
        self._item_values = list(itemdict.values())
        self.selectionChanged.connect(self.targetValueChanged)
[docs]    def targetSetValue(self, checked_values):
        # See parent class for documentation
        selected_indexes = [
            i for i, val in enumerate(self._item_values)
            if val in checked_values
        ]
        self.setSelectedIndexes(selected_indexes)
[docs]    def targetGetValue(self):
        # See parent class for documentation
        return {
            val for i, val in enumerate(self._item_values)
            if self.isIndexSelected(i)
        }
class _HideWhenDisabledMappableComboBox(MappableMultiComboBox):
    def __init__(self, parent=None, **kwargs):
        super().__init__(parent=parent, **kwargs)
        self.setStyleSheet(stylesheets.DARK_COMBOBOX)
    def setEnabled(self, enable):
        """
        Hide the combobox when it's not enabled.
        """
        super().setEnabled(enable)
        self.setVisible(enable)
[docs]class ColorSchemeComboBox(color_widgets.ColorSchemeComboBoxMixin, MsvComboBox):
    """
    A combo box that allows the user to select a color scheme or open the
    "Define Custom Color Scheme" dialog.
    :ivar defineCustomColorSchemeRequested: Signal emitted when the user selects
        "Define Custom Scheme...".
    :vartype defineCustomColorSchemeRequested: QtCore.pyqtSignal
    :ivar schemeChanged: Signal emitted when the user selects a scheme.
    :vartype schemeChanged: QtCore.pyqtSignal
    """
    defineCustomColorSchemeRequested = QtCore.pyqtSignal()
    schemeChanged = QtCore.pyqtSignal()
    DEFINE_CUSTOM = "Define Custom Scheme..."
    _settingCustomVisibility = infra_util.flag_context_manager(
        "_setting_custom_visibility")
[docs]    def __init__(self, parent=None):
        super().__init__(parent)
        added = self._populateComboBox(HAS_WORKSPACE,
                                       items_to_add=(self._SEPARATOR,
                                                     self.DEFINE_CUSTOM))
        self._define_custom_index = added.index(self.DEFINE_CUSTOM)
        self._setting_custom_visibility = False
        self._prev_index = 0
        self.currentIndexChanged.connect(self._onCurIndexChanged)
    @infra_util.skip_if("_setting_custom_visibility")
    def _onCurIndexChanged(self, idx):
        if idx == self._define_custom_index:
            with qt_utils.suppress_signals(self):
                # Make sure we don't leave "Define Custom Scheme..." selected
                self.setCurrentIndex(self._prev_index)
            self.defineCustomColorSchemeRequested.emit()
        else:
            self._prev_index = idx
            super()._onCurrentIndexChanged(idx)
            self.schemeChanged.emit()
[docs]    def setCustomSchemeVisible(self, visible):
        """
        Set whether the "Custom" entry is shown or hidden.
        :param visible: Whether the "Custom" entry should be shown.
        :type visible: bool
        """
        if bool(visible) == self._custom_shown:
            return
        with self._settingCustomVisibility():
            if visible:
                self._define_custom_index += 1
                self._showCustom(self.CUSTOM)
            else:
                self._define_custom_index -= 1
                self._hideCustom()
[docs]class BaseMsvPopUp(mappers.MapperMixin, pop_up_widgets.PopUp):
    """
    Base class for MSV popup dialogs.
    Subclasses should define self._widget_to_model_map, the mapping between
    widgets and option model.
    """
    model_class = gui_models.OptionsModel
[docs]    def __init__(self, parent=None):
        super().__init__(parent)
        self.setObjectName('base_msv_pop_up')
        self.setStyleSheet(stylesheets.WHITE_BG_WIDGET_STYLE)
[docs]    def setup(self):
        """
        Sets up all components of the dialog. Should not be overridden by
        derived classes. Instead, they should override _setupWidgets.
        """
        self.name_widget = {}
        self.main_layout = QtWidgets.QVBoxLayout(self)
        # MSV-1261 - Need to remove this padding outside of the stylesheets. See
        # https://stackoverflow.com/questions/24867987/qt-stylesheets-how-to-remove-dead-space
        self.main_layout.setContentsMargins(0, 0, 0, 0)
        self.main_layout.setSpacing(0)
        self._setupTitleWidget()
        self._setupWidgets()
        self._setupMapperMixin()
    def _setupTitleWidget(self):
        """
        Derived classes should override this function to set up the title.
        """
    def _setupWidgets(self):
        """
        Derived classes should override this function to set up widgets.
        """
[docs]    def createCircleCheckBox(self, label_text, set_checked=False):
        """
        Create and return a checkbox with the circle icon.
        :param label_text: Text for the checkbox label
        :type label_text: str
        :param set_checked: Whether to set the checkbox as checked by default
        :type set_checked: bool
        :return: The circle icon checkbox
        :rtype: `QtWidgets.QCheckBox`
        """
        cb = QtWidgets.QCheckBox(label_text)
        cb.setStyleSheet(stylesheets.CIRCLE_CHECKBOX)
        cb.setChecked(set_checked)
        return cb
[docs]    def createSubTitleLabel(self, label_text):
        """
        Create and return a sub title label with the sub title style applied.
        :param label_text: text for the sub title label
        :type label_text: str
        :return: the newly created sub title label
        :rtype: `QtWidgets.QLabel`
        """
        return DarkBackgroundLabel(label_text,
                                   style=stylesheets.DARK_BG_SUB_TITLE_STYLE)
[docs]    def createVerticalLayout(self, indent=True):
        """
        Create a vertical layout and set its margins.
        :return: The created vertical layout
        :rtype: `QtWidgets.QVBoxLayout`
        """
        vlayout = QtWidgets.QVBoxLayout()
        self._setLayoutMargins(vlayout, indent=indent)
        return vlayout
[docs]    def createHorizontalLayout(self, indent=True):
        """
        Create a horizontal layout and set its margins.
        :return: The created horizontal layout
        :rtype: `QtWidgets.QHBoxLayout`
        """
        hlayout = QtWidgets.QHBoxLayout()
        self._setLayoutMargins(hlayout, indent=indent)
        return hlayout
[docs]    def createGridLayout(self, indent=True, items=None):
        """
        Create a grid layout and set its margins.
        :param items: List of lists of items to add to the layout, or None to
            leave a gap in that position
        :type items: list[list[Union[QWidget, QLayout, None]]]
        :return: The grid layout
        :rtype: QtWidgets.QGridLayout
        """
        glayout = QtWidgets.QGridLayout()
        self._setLayoutMargins(glayout, indent=indent)
        if items is not None:
            qt_utils.add_items_to_glayout(glayout, items)
        return glayout
    def _setLayoutMargins(self, layout, indent=True):
        """
        Sets the margins of the specified layout to the standard amounts
        for this widget.
        :param layout: Layout to set the margins of
        :type layout: `QtWidgets.QLayout`
        """
        left = 10 if indent else 0
        layout.setContentsMargins(left, LAYOUT_ROW_PADDING, 0,
                                  LAYOUT_ROW_PADDING)
[docs]class BaseMsvPopUpWithTitle(BaseMsvPopUp):
    """
    Base class for MSV popup dialogs with the title bar.
    See BaseMsvPopUp for more information.
    """
    def _setupTitleWidget(self):
        """
        Helper method to setup the title area of the pop-up pane.
        """
        self.pane_title_lbl = DarkBackgroundLabel(
            "TITLE", style=stylesheets.DARK_BG_TITLE_STYLE)
        self.main_layout.addWidget(self.pane_title_lbl)
        self.main_layout.addSpacing(7)
[docs]    def setTitle(self, title):
        """
        Set the title of the pop-up pane.
        :param title: to set at the top label of the pane.
        :type title: str
        """
        self.pane_title_lbl.setText(title)
[docs]class ViewStylePopUp(widgetmixins.MessageBoxMixin, BaseMsvPopUpWithTitle):
    """
    A popup dialog to provide options for viewing style
    """
    openPropertyDialogRequested = QtCore.pyqtSignal()
    model_class = gui_models.PageModel
    GRID_LAYOUT_STRETCH_FACTOR = 1
    GRID_LAYOUT_CONTENT_MARGINS = (15, 10, 15, 10)
    GRID_LAYOUT_HORIZONTAL_SPACING = 30
    GRID_LAYOUT_VERTICAL_SPACING = 20
    GRID_LAYOUT_COL_MIN_WIDTH = 50
    H_LAYOUT_CONTENT_MARGINS = (5, 0, 5, 5)
[docs]    def canToggleSplitChains(self):
        """
        Check whether it's possible to toggle `split_chain_view`.
        If there are anchored residues or alignment sets, then prompt the user
        to confirm that they're okay with clearing them.  If there are pairwise
        constraints, prompt the user to confirm that they're okay with clearing
        them, then clear them if okayed.
        :return: Whether it's possible to toggle `split_chain_view`
        :rtype: bool
        """
        aln = self.model.aln
        title = "Toggle split chain view"
        if aln.getAnchoredResidues():
            text = ("This change will cause anchors to be removed. Continue "
                    "anyway?")
            if not self.question(text, title):
                return False
        if aln.hasAlnSets():
            msg = ('This change will dissolve all Alignment Sets. '
                   'Continue anyway?')
            if not self.question(msg, title):
                return False
        if aln.pairwise_constraints.hasConstraints():
            msg = ('This change will clear pairwise constraints. '
                   'Continue anyway?')
            if self.question(msg, title):
                aln.resetPairwiseConstraints()
                self.model.options.align_settings.pairwise.set_constraints = False
            else:
                return False
        if aln.anyHidden():
            msg = ('This change will show all hidden seqs. Continue anyway?')
            if self.question(msg, title):
                aln.showAllSeqs()
            else:
                return False
        return True
    def _setupWidgets(self):
        """
        Set up the widgets for this dialog.
        """
        self.setTitle('VIEW OPTIONS')
        # Alignment Calculations widgets
        aln_calc_label = self.createSubTitleLabel("ALIGNMENT CALCULATIONS")
        self.main_layout.addWidget(aln_calc_label)
        compute_for_columns_tooltips = {
            "All": "Compute for all columns defined by the Reference",
            "Selected": "Compute only for the columns defined by selection in the Reference"
        }
        self.col_toggle = SideBySideToggle(
            "Compute for columns:",
            (("All", viewconstants.ColumnMode.AllColumns),
             ("Selected", viewconstants.ColumnMode.SelectedColumns)),
            viewconstants.ColumnMode.SelectedColumns,
            tooltips=compute_for_columns_tooltips)
        self.col_toggle.layout().setContentsMargins(0, 10, 0, 10)
        self.gap_chb = self.createCircleCheckBox("Include Gaps")
        self.by_identity = self.createCircleCheckBox("Identity %")
        self.by_similarity = self.createCircleCheckBox("Similarity %")
        self.by_conservation = self.createCircleCheckBox("Conservation %")
        self.by_score = self.createCircleCheckBox("Overall Score")
        aln_layout = self.createGridLayout()
        show_lbl = GrayLabel('Show:')
        aln_layout.addWidget(show_lbl, 0, 0, Qt.AlignLeft)
        toggles = ((self.by_identity, self.by_similarity),
                   (self.by_conservation, self.by_score))
        for row, toggle_pair in enumerate(toggles):
            for col, toggle in enumerate(toggle_pair, 1):
                aln_layout.addWidget(toggle, row, col)
        aln_layout.addWidget(self.col_toggle, 2, 0, 1, 2, Qt.AlignLeft)
        aln_layout.addWidget(self.gap_chb, 2, 2, Qt.AlignVCenter)
        aln_layout.setColumnMinimumWidth(0, self.GRID_LAYOUT_COL_MIN_WIDTH)
        aln_layout.setColumnStretch(1, self.GRID_LAYOUT_STRETCH_FACTOR)
        aln_layout.setColumnStretch(2, self.GRID_LAYOUT_STRETCH_FACTOR)
        aln_layout.setHorizontalSpacing(self.GRID_LAYOUT_HORIZONTAL_SPACING)
        aln_layout.setVerticalSpacing(self.GRID_LAYOUT_VERTICAL_SPACING)
        aln_layout.setContentsMargins(*self.GRID_LAYOUT_CONTENT_MARGINS)
        self.main_layout.addLayout(aln_layout)
        # Sequence Display widgets
        seq_display_lbl = self.createSubTitleLabel("SEQUENCE DISPLAY")
        self.show_properties_lbl = hyperlink.SimpleLink("Show properties... ")
        self.show_properties_lbl.clicked.connect(
            self.openPropertyDialogRequested)
        seq_display_layout = self.createHorizontalLayout(indent=False)
        seq_display_layout.setContentsMargins(0, 0, 0, 0)
        seq_display_layout.addWidget(seq_display_lbl)
        seq_display_layout.addStretch()
        seq_display_layout.addWidget(self.show_properties_lbl)
        seq_display_frame = QtWidgets.QFrame()
        seq_display_frame.setStyleSheet("QFrame {border: none; "
                                        "background-color: #242424;}")
        seq_display_frame.setFixedHeight(25)
        seq_display_frame.setLayout(seq_display_layout)
        self.main_layout.addWidget(seq_display_frame)
        self.wrap_seq = self.createCircleCheckBox("Wrap Sequences", False)
        self.split_chain = self.createCircleCheckBox("Split Chains", True)
        self.split_chain.clicked.connect(self._splitChainClicked)
        wrap_layout = self.createGridLayout()
        wrap_layout.addWidget(self.wrap_seq, 0, 1, Qt.AlignVCenter)
        wrap_layout.addWidget(self.split_chain, 0, 2, Qt.AlignVCenter)
        wrap_layout.setColumnMinimumWidth(
            0, (aln_layout.columnMinimumWidth(0) + show_lbl.minimumWidth() +
                aln_layout.horizontalSpacing()))
        wrap_layout.setColumnStretch(1, self.GRID_LAYOUT_STRETCH_FACTOR)
        wrap_layout.setColumnStretch(2, self.GRID_LAYOUT_STRETCH_FACTOR)
        wrap_layout.setHorizontalSpacing(self.GRID_LAYOUT_HORIZONTAL_SPACING)
        wrap_layout.setContentsMargins(*self.GRID_LAYOUT_CONTENT_MARGINS)
        self.main_layout.addLayout(wrap_layout)
        format_layout = self.createHorizontalLayout()
        format_layout.setContentsMargins(*self.H_LAYOUT_CONTENT_MARGINS)
        self.format_toggle = SideBySideToggle(
            "Format:", (("A", viewconstants.ResidueFormat.OneLetter),
                        ("ALA", viewconstants.ResidueFormat.ThreeLetter),
                        ("", viewconstants.ResidueFormat.HideLetters)),
            viewconstants.ResidueFormat.OneLetter)
        self.identity_toggle = SideBySideToggle(
            "Identities:", (("A", viewconstants.IdentityDisplayMode.Residue),
                            (viewconstants.DEFAULT_GAP,
                             viewconstants.IdentityDisplayMode.MidDot)),
            viewconstants.IdentityDisplayMode.Residue)
        self.font_widget = PlusMinusWidget(self)
        for widget in (self.format_toggle, self.identity_toggle,
                       self.font_widget):
            format_layout.addWidget(widget)
        self.main_layout.addLayout(format_layout)
    def _setNotImplemented(self, widget):
        widget.setEnabled(False)
        widget.setToolTip("Not implemented")
    def _splitChainClicked(self, enabled):
        """
        Respond to a click on the "Split Chains" checkbox.
        If there are no anchored residues, alignment sets, or pairwise
        constraints, then simply update the model's split chain setting.
        Otherwise, the user is prompted whether they are okay with clearing
        these in order to toggle `split_chain_view`.
        :param enabled: Whether the checkbox was checked or unchecked.
        :type enabled: bool
        """
        if self.canToggleSplitChains():
            # If the user is okay with it, update the model to match the view
            self.model.split_chain_view = enabled
        else:
            # Otherwise, reset the view to match the model. This will not
            # trigger this slot because `setChecked` does not cause `clicked`
            # to be emitted.
            self.split_chain.setChecked(self.model.split_chain_view)
[docs]    def setModel(self, model):
        # See parent class for method documentation
        if self.model is not None:
            self.model.split_chain_viewChanged.disconnect(
                self.split_chain.setChecked)
        super().setModel(model)
        if model is not None:
            self.split_chain.setChecked(model.split_chain_view)
            model.split_chain_viewChanged.connect(self.split_chain.setChecked)
[docs]    def defineMappings(self):
        # We intentionally don't connect split_chains_view here since we may
        # need to prompt the user about anchors before we actually change the
        # value.  See _splitChainClicked above.
        M = gui_models.PageModel.options
        return [
            (self.by_conservation, M.show_conservation_col),
            (self.by_identity, M.show_identity_col),
            (self.by_score, M.show_score_col),
            (self.by_similarity, M.show_similarity_col),
            (self.col_toggle, M.compute_for_columns),
            (self.font_widget, M.font_size),
            (self.format_toggle, M.res_format),
            (self.gap_chb, M.include_gaps),
            (self.identity_toggle, M.identity_display),
            (self.wrap_seq, M.wrap_sequences)
        ] # yapf:disable
[docs]class CheckBoxGroup(mappers.TargetMixin, QtCore.QObject):
    """
    Class for mapping a set of checkboxes to a set where each checkbox
    represents some value. If the checkbox is checked, the value will be
    added to the set and vice-versa. Note that this class does not
    create any widgets itself and is simply a target for syncing a `SetParam`
    and a set of checkboxes.
    """
[docs]    def __init__(self, value_cb_pairs):
        """
        :param value_cb_pairs: A list of 2-tuples. The first value of each
            tuple is the value associated with the checkbox and the second
            value is the actual checkbox.
        :type  value_cb_pairs: list[tuple(object, QCheckBox)]
        """
        super().__init__()
        for _, cb in value_cb_pairs:
            cb.stateChanged.connect(self.targetValueChanged)
        self.value_cb_pairs = value_cb_pairs
[docs]    def targetGetValue(self):
        return {value for value, cb in self.value_cb_pairs if cb.isChecked()}
[docs]    def targetSetValue(self, new_value_set):
        with suppress_signals(self):
            for value, cb in self.value_cb_pairs:
                cb.setChecked(value in new_value_set)
        self.targetValueChanged.emit()
[docs]class EnumCheckBox(mappers.TargetMixin, QtWidgets.QCheckBox):
    """
    Class for mapping a checkbox to an EnumParam with two values
    """
[docs]    def __init__(self, label_text, enum, checked_index=1):
        """
        :param label_text: Text to show next to the QCheckBox
        :param enum: 2-value enum to map to the checkbox state
        :param checked_index: Enum index corresponding to checked
        """
        super().__init__(label_text)
        self.setStyleSheet(stylesheets.CIRCLE_CHECKBOX)
        assert len(enum) == 2
        assert 0 <= checked_index <= 1
        self._enum = enum
        self.checked_index = checked_index
        self.stateChanged.connect(self.targetValueChanged)
    def _getEnumValue(self, index: int):
        return list(self._enum)[index]
[docs]    def targetGetValue(self):
        if self.isChecked():
            return self._getEnumValue(self.checked_index)
        else:
            return self._getEnumValue(0 if self.checked_index else 1)
[docs]    def targetSetValue(self, new_value):
        assert isinstance(new_value, self._enum)
        enable = new_value is self._getEnumValue(self.checked_index)
        self.setChecked(enable)
[docs]class QuickAnnotationPopUp(BaseMsvPopUpWithTitle):
    """
    Popup dialog for performing annotations.
    """
    GRID_LAYOUT_SPACING = 15
    GRID_LAYOUT_MARGINS = (15, 15, 15, 15)
    H_LAYOUT_MARGINS = GRID_LAYOUT_MARGINS
    def _setupWidgets(self):
        """
        Set up the widgets for this popup.
        """
        self.setTitle('ANNOTATIONS')
        # Sequence Annotations widgets
        self.sequence_label = self.createSubTitleLabel("SEQUENCE ANNOTATIONS")
        self.main_layout.addWidget(self.sequence_label)
        self.disulfide_cb = self.createCircleCheckBox("Disulfide Bonds")
        self.secondary_structure_cb = self.createCircleCheckBox(
            "Secondary Structure Assignment")
        self.b_factor_cb = self.createCircleCheckBox("B Factor")
        self.binding_sites_cb = self.createCircleCheckBox("Binding Site")
        self.binding_sites_combo = BindingSiteDistanceComboBox()
        self.binding_sites_combo.setVisible(False)
        self.hydrophobicity_cb = self.createCircleCheckBox("Hydrophobicity")
        self.iso_point_cb = self.createCircleCheckBox("Isoelectric Point")
        self.res_num_cb = self.createCircleCheckBox("Residue Numbers")
        self.domain_cb = self.createCircleCheckBox("Domains")
        self.kinase_feat_cb = self.createCircleCheckBox("Kinase Features")
        self.kinase_cons_cb = self.createCircleCheckBox(
            "Kinase Binding Site Conservation")
        self.antibody_cdr_cb = self.createCircleCheckBox("Antibody CDRs")
        self.antibody_cdr_combo = MsvComboBox(self)
        self.antibody_cdr_combo.addItems(ANTIBODY_CDR_ITEMS)
        self.antibody_cdr_combo.setVisible(False)
        self.antibody_cdr_combo.setTooltips(ANTIBODY_CDR_TOOLTIPS)
        self.res_propensity_cb = self.createCircleCheckBox("Residue Propensity")
        self.res_propensity_combo = _HideWhenDisabledMappableComboBox(
            itemdict=RESIDUE_PROPENSITY_ITEMS)
        self.res_propensity_combo.setVisible(False)
        binding_sites_layout = QtWidgets.QHBoxLayout()
        binding_sites_layout.addWidget(self.binding_sites_cb)
        binding_sites_layout.addWidget(self.binding_sites_combo)
        seq_grid = (
            (self.disulfide_cb, self.secondary_structure_cb),
            (self.b_factor_cb, binding_sites_layout),
            (self.hydrophobicity_cb, self.iso_point_cb),
            (self.res_num_cb, self.domain_cb),
            (self.kinase_feat_cb, self.kinase_cons_cb),
            (self.antibody_cdr_cb, self.antibody_cdr_combo),
            (self.res_propensity_cb, self.res_propensity_combo),
        )  # yapf: disable
        seq_layout = self.createGridLayout(items=seq_grid)
        # Stretch out the last column so that all toggles are tightly bound
        seq_layout.setColumnStretch(2, 2)
        self.main_layout.addLayout(seq_layout)
        anno_cb_pairs = [
            (SEQ_ANNO.disulfide_bonds, self.disulfide_cb),
            (SEQ_ANNO.secondary_structure, self.secondary_structure_cb),
            (SEQ_ANNO.b_factor, self.b_factor_cb),
            (SEQ_ANNO.binding_sites, self.binding_sites_cb),
            (SEQ_ANNO.window_hydrophobicity, self.hydrophobicity_cb),
            (SEQ_ANNO.resnum, self.res_num_cb),
            (SEQ_ANNO.window_isoelectric_point, self.iso_point_cb),
            (SEQ_ANNO.antibody_cdr, self.antibody_cdr_cb),
            (SEQ_ANNO.domains, self.domain_cb),
            (SEQ_ANNO.kinase_conservation, self.kinase_cons_cb),
        ]  # yapf: disable
        self.seq_anno_cbgroup = CheckBoxGroup(anno_cb_pairs)
        # Alignment Annotations widgets
        self.alignment_label = self.createSubTitleLabel("ALIGNMENT ANNOTATIONS")
        self.main_layout.addWidget(self.alignment_label)
        self.cons_seq_cb = self.createCircleCheckBox("Consensus Sequence")
        self.mean_hydro_cb = self.createCircleCheckBox("Mean Hydrophobicity")
        self.seq_logo_cb = self.createCircleCheckBox("Sequence Logo")
        self.cons_symbols_cb = self.createCircleCheckBox("Consensus Symbols")
        self.mean_iso_cb = self.createCircleCheckBox("Mean Isoelectric Point")
        aln_grid = (
            (self.cons_seq_cb, self.mean_hydro_cb, self.seq_logo_cb),
            (self.cons_symbols_cb, self.mean_iso_cb),
        )  # yapf: disable
        aln_layout = self.createGridLayout(items=aln_grid)
        aln_anno_cb_pairs = [
            (ALN_ANNO.mean_hydrophobicity, self.mean_hydro_cb),
            (ALN_ANNO.mean_isoelectric_point, self.mean_iso_cb),
            (ALN_ANNO.consensus_symbols, self.cons_symbols_cb),
            (ALN_ANNO.consensus_seq, self.cons_seq_cb),
            (ALN_ANNO.sequence_logo, self.seq_logo_cb),
        ]
        self.aln_anno_cbgroup = CheckBoxGroup(aln_anno_cb_pairs)
        self.main_layout.addLayout(aln_layout)
        # Annotation Settings widgets
        self.settings_label = self.createSubTitleLabel("ANNOTATION SETTINGS")
        self.main_layout.addWidget(self.settings_label)
        settings_layout = self.createHorizontalLayout()
        self.group_by_type_cb = EnumCheckBox("Group by Type",
                                             viewconstants.GroupBy)
        settings_layout.addWidget(self.group_by_type_cb)
        hspacer = QtWidgets.QSpacerItem(40, 0,
                                        QtWidgets.QSizePolicy.MinimumExpanding)
        settings_layout.addSpacerItem(hspacer)
        self.reset_all_btn = StyledButton("Clear All")
        self.reset_all_btn.clicked.connect(self.resetMappedParams)
        settings_layout.addWidget(self.reset_all_btn)
        self.main_layout.addLayout(settings_layout)
        self.antibody_cdr_enabled = True
        # If we're in debug mode, we only hookup antibody CDR if the required
        # dependencies are installed
        if utils.DEBUG_MODE:
            missing_dependencies = (ANNO_DEPENDENCIES[SEQ_ANNO.antibody_cdr] -
                                    INSTALLED_DEPENDENCIES)
            if missing_dependencies:
                self.antibody_cdr_enabled = False
[docs]    def defineMappings(self):
        M = gui_models.OptionsModel
        res_prop_target = mappers.TargetSpec(self.res_propensity_cb,
                                             slot=self._updateResiduePropensity)
        # FIXME: After PANEL-17924 QCheckBox and the param will be in sync, we
        # should then use the infra code.
        kinase_feat_target = mappers.TargetSpec(
            self.kinase_feat_cb, signal=self.kinase_feat_cb.toggled)
        return [
            (self.group_by_type_cb, M.group_by),
            (self.binding_sites_combo, M.binding_site_distance),
            (res_prop_target, M.residue_propensity_enabled),
            (self.res_propensity_combo, M.residue_propensity_annotations),
            (self.antibody_cdr_cb, M.antibody_cdr),
            (self.antibody_cdr_combo, M.antibody_cdr_scheme),
            (self.seq_anno_cbgroup, M.sequence_annotations),
            (self.aln_anno_cbgroup, M.alignment_annotations),
            (kinase_feat_target, M.kinase_features),
        ] # yapf: disable
[docs]    def getSignalsAndSlots(self, model):
        ss = [
            (self.binding_sites_cb.toggled, self._updateBindingSites),
            (model.sequence_annotationsChanged, self._enableAnnotations),
            (model.alignment_annotationsChanged, self._enableAnnotations),
            (model.predicted_annotationsChanged, self._enableAnnotations),
            (model.residue_propensity_enabledChanged, self._enableAnnotations),
            (model.residue_propensity_annotationsChanged, self._onResPropAnnosChanged),
            (model.kinase_features_enabledChanged, self._enableAnnotations),
            (model.kinase_featuresChanged, self._onKinaseFeaturesChanged),
        ]  # yapf: disable
        if self.antibody_cdr_enabled:
            ss.append((self.antibody_cdr_cb.toggled, self._updateAntibodyCDR))
        else:
            ss.append(
                (self.antibody_cdr_cb.toggled, self._disabledAntibodyCDRSlot))
        return ss
[docs]    def createGridLayout(self, **kwargs):
        """
        Add the appropriate spacing and content margins for annotations pane.
        See parent `BaseMsvPopUpWithTitle` for documentation.
        """
        layout = super().createGridLayout(**kwargs)
        layout.setSpacing(self.GRID_LAYOUT_SPACING)
        layout.setContentsMargins(*self.GRID_LAYOUT_MARGINS)
        return layout
[docs]    def createHorizontalLayout(self, indent=True):
        """
        Add the appropriate content margins for annotations pane.
        See parent `BaseMsvPopUpWithTitle` for documentation.
        """
        layout = super().createHorizontalLayout(indent)
        layout.setContentsMargins(*self.H_LAYOUT_MARGINS)
        return layout
    @QtCore.pyqtSlot()
    def _enableAnnotations(self):
        self.model.annotations_enabled = True
    @QtCore.pyqtSlot(object)
    @QtCore.pyqtSlot(list)
    def _onResPropAnnosChanged(self, res_prop_annos):
        if res_prop_annos:
            # Note: residue_propensity_annotationsChanged is
            # connected to _enableAnnotations, so we don't explicitly call
            # _enableAnnotations here to avoid multiple signals
            self.model.residue_propensity_enabled = True
        else:
            self._enableAnnotations()
    @QtCore.pyqtSlot()
    def _updateBindingSites(self):
        self.binding_sites_combo.setEnabled(self.binding_sites_cb.isChecked())
    def _updateResiduePropensity(self):
        show_res_propensity = self.model.residue_propensity_enabled
        self.res_propensity_combo.setEnabled(show_res_propensity)
    @QtCore.pyqtSlot()
    def _updateAntibodyCDR(self):
        show_antibody_cdr = self.antibody_cdr_cb.isChecked()
        self.antibody_cdr_combo.setVisible(show_antibody_cdr)
    def _disabledAntibodyCDRSlot(self):
        feature = 'antibody cdr annotations'
        missing_dependencies = (ANNO_DEPENDENCIES[SEQ_ANNO.antibody_cdr] -
                                INSTALLED_DEPENDENCIES)
        with suppress_signals(self.antibody_cdr_cb):
            self.antibody_cdr_cb.setCheckState(False)
        self._showMissingDependencyWarning(feature, missing_dependencies)
    def _onKinaseFeaturesChanged(self):
        """
        Check whether prime is installed before enabling kinase annotations.
        """
        if not self.model.kinase_features:
            self.model.kinase_features_enabled = False
            return
        if not is_prime_installed():
            self.model.kinase_features = False
            msg = "Prime not installed, kinase features cannot be found."
            messagebox.show_warning(self,
                                    msg,
                                    title='Kinase Feature Annotation')
            return
        accepted = True
        self.model.kinase_features = accepted
        self.model.kinase_features_enabled = accepted
    def _showMissingDependencyWarning(self, feature, dependencies):
        """
        Method that shows a warning message telling the developer what missing
        dependencies they need to install to use `feature`.
        :param feature: The feature that is missing dependencies
        :type  feature: string
        :param dependencies: The missing dependencies
        :type  dependencies: set(Dependency)
        """
        warning_msg = ('You must install the following missing packages before '
                       'using %s: ' % feature +
                       ', '.join([d.name for d in dependencies]))
        QtWidgets.QMessageBox.warning(self.parent(), 'Dependency not found',
                                      warning_msg)
[docs]class ColorPopUp(BaseMsvPopUpWithTitle):
    """
    A popup dialog to provide options for color settings.
    :ivar colorSelResiduesRequested: Signal emitted when one of the
        "Paint Selected" color buttons is clicked. Emits the RGB color to color
        the residues.
    :ivar clearHighlightsRequested: Signal emitted when the
        "Clear All Highlights" lbl is clicked.
    :ivar applyColorsToWorkspaceRequested: Signal emitted when the
        "Color Carbons" or "Color Residues" button is clicked. Emitted with
        whether to color all atoms.
    :ivar defineCustomColorSchemeRequested: Signal emitted when the user selects
        "Define Custom Scheme...".
    """
    colorSelResRequested = QtCore.pyqtSignal(tuple)
    clearHighlightsRequested = QtCore.pyqtSignal()
    applyColorsToWorkspaceRequested = QtCore.pyqtSignal(bool)
    defineCustomColorSchemeRequested = QtCore.pyqtSignal()
    COLOR_WS_TEXT = "Color Carbons"
    COLOR_WS_RES_TEXT = "Color Residues"
    GRID_LAYOUT_VERTICAL_SPACING = 10
    COLOR_PIXMAP_SIZE = 16
    COLOR_IMAGE_SIZE = 256
    def _setupWidgets(self):
        self.setTitle("COLOR SEQUENCES")
        ### Settings section ###
        settings_lbl = self.createSubTitleLabel("COLOR SCHEMES & SETTINGS")
        settings_sublayout = self.createHorizontalLayout(indent=False)
        self.reset_colors_lbl = self.createSubTitleLabel("Reset")
        self.reset_colors_lbl.setObjectName("reset_colors_lbl")
        self.reset_colors_lbl.setAlignment(Qt.AlignRight | Qt.AlignVCenter)
        settings_sublayout.addWidget(settings_lbl)
        settings_sublayout.addWidget(self.reset_colors_lbl)
        apply_lbl = GrayLabel("Apply to:")
        self.color_seq_aln_combo = MsvComboBox(self)
        self.color_seq_aln_combo.addItems(COLOR_SEQ_ALN_ITEMS)
        for idx in range(self.color_seq_aln_combo.count()):
            item = self.color_seq_aln_combo.itemData(idx)
            tooltip = COLOR_SEQ_ALN_TOOLTIPS[item]
            self.color_seq_aln_combo.setItemData(idx, tooltip, Qt.ToolTipRole)
        separator_positions = [
            self.color_seq_aln_combo.findData(data)
            for data in (viewconstants.ColorByAln.MatchingCons,
                         viewconstants.ColorByAln.Unset)
        ]
        for sep_idx, before_position in enumerate(sorted(separator_positions)):
            position = before_position + sep_idx
            self.color_seq_aln_combo.insertSeparator(position)
        color_by_lbl = GrayLabel("Color by:")
        # TODO MSV-1800 Add new color schemes and separators
        self.color_seq_combo = ColorSchemeComboBox(self)
        self.color_seq_combo.defineCustomColorSchemeRequested.connect(
            self.defineCustomColorSchemeRequested)
        self.color_scheme_info_btn = make_pop_up_tool_button(
            parent=self,
            pop_up_class=ColorSchemeInfoPopUp,
            obj_name="color_scheme_info_btn",
            indicator=False)
        self.color_info_popup = self.color_scheme_info_btn.popup_dialog
        self.weight_by_quality_cb = self.createCircleCheckBox(
            "Weight colors by alignment quality", False)
        self.average_in_cols_cb = self.createCircleCheckBox(
            "Average colors in columns", False)
        self.reset_colors_lbl.clicked.connect(self._resetColorOptions)
        settings_grid = (
            (apply_lbl, self.color_seq_aln_combo),
            (color_by_lbl, self.color_seq_combo, self.color_scheme_info_btn),
            (None, self.weight_by_quality_cb),
            (None, self.average_in_cols_cb)
        )  # yapf: disable
        settings_layout = self.createGridLayout(items=settings_grid)
        settings_layout.setVerticalSpacing(self.GRID_LAYOUT_VERTICAL_SPACING)
        ### Highlight section ###
        highlight_lbl = self.createSubTitleLabel("HIGHLIGHT SELECTED RESIDUES")
        self.clear_highlight_lbl = self.createSubTitleLabel(
            "Clear All Highlights")
        self.clear_highlight_lbl.setObjectName("clear_all_highlights_lbl")
        self.clear_highlight_lbl.setAlignment(Qt.AlignRight | Qt.AlignVCenter)
        highlight_sublayout = self.createHorizontalLayout(indent=False)
        highlight_sublayout.addWidget(highlight_lbl)
        highlight_sublayout.addWidget(self.clear_highlight_lbl)
        paint_lbl = GrayLabel("Paint selected:")
        self.paint_widget = self._createColorBtnWidget(HIGHLIGHT_COLORS,
                                                       custom=True)
        paint_layout = self.createHorizontalLayout(indent=False)
        paint_layout.addWidget(paint_lbl)
        paint_layout.addSpacing(20)
        paint_layout.addWidget(self.paint_widget)
        paint_layout.addSpacing(10)
        # MSV-3357: Hiding unimplemented features for beta
        #outline_lbl = GrayLabel("Outline selected blocks:")
        #outline_widget = self._createColorBtnWidget(OUTLINE_COLORS)
        #outline_layout = self.createHorizontalLayout(indent=False)
        #outline_layout.addWidget(outline_lbl)
        #outline_layout.addSpacing(20)
        #outline_layout.addWidget(outline_widget)
        #outline_layout.addStretch()
        highlight_layout = self.createVerticalLayout()
        highlight_layout.addLayout(paint_layout)
        #highlight_layout.addLayout(outline_layout)
        # Add everything to main layout
        self.main_layout.addLayout(settings_sublayout)
        self.main_layout.addLayout(settings_layout)
        self.main_layout.addLayout(highlight_sublayout)
        self.main_layout.addLayout(highlight_layout)
        ### Workspace section ###
        if HAS_WORKSPACE:
            workspace_lbl = self.createSubTitleLabel("COLOR LINKED STRUCTURES")
            apply_lbl = GrayLabel("Apply colors to structures:")
            self.whenever_cb = self.createCircleCheckBox("Whenever they change")
            self.entire_cb = self.createCircleCheckBox("Color entire residues")
            self.color_ws_btn = StyledButton(self.COLOR_WS_TEXT)
            self.color_ws_btn.clicked.connect(self._requestColorWorkspace)
            workspace_sublayout = self.createHorizontalLayout(indent=False)
            workspace_sublayout.addWidget(self.whenever_cb)
            workspace_sublayout.addWidget(self.entire_cb)
            workspace_sublayout.addWidget(self.color_ws_btn)
            workspace_layout = self.createVerticalLayout()
            workspace_layout.addWidget(apply_lbl)
            workspace_layout.addLayout(workspace_sublayout)
            self.main_layout.addWidget(workspace_lbl)
            self.main_layout.addLayout(workspace_layout)
        # TODO Fix disabled parts
        # TODO MSV-1438 implement user-defined annotations
        #self._setNotImplemented(outline_lbl)
        #self._setNotImplemented(outline_widget)
[docs]    def defineMappings(self):
        M = gui_models.OptionsModel
        color_scheme_target = mappers.TargetSpec(
            self.color_seq_combo,
            getter=self._getCurrentScheme,
            setter=self._setCurrentScheme,
            signal=self.color_seq_combo.schemeChanged)
        mappings = [
            (self.color_seq_aln_combo, M.color_by_aln),
            (self.weight_by_quality_cb, M.weight_by_quality),
            (self.average_in_cols_cb, M.average_in_cols),
            (color_scheme_target, M.seq_color_scheme),
            (self.color_info_popup, M.seq_color_scheme),
            (self._onCustomColorSchemeChanged, M.custom_color_scheme)
        ]  # yapf: disable
        if HAS_WORKSPACE:
            mappings.extend([
                (self.whenever_cb, M.ws_color_sync),
                (self.entire_cb, M.ws_color_all_atoms),
            ])
        return mappings
[docs]    def getSignalsAndSlots(self, model):
        ss = [
            (self.clear_highlight_lbl.clicked, self.clearHighlightsRequested),
            (model.color_by_alnChanged, self._onColorOptionChanged),
            (model.weight_by_qualityChanged, self._onColorOptionChanged),
            (model.average_in_colsChanged, self._onColorOptionChanged),
            (model.seq_color_schemeChanged, self._onColorOptionChanged)
        ]  # yapf: disable
        if HAS_WORKSPACE:
            ss.append((model.ws_color_all_atomsChanged,
                       self._updateColorWorkspaceButton))
            for signal in (
                    model.seq_color_schemeChanged,
                    model.custom_color_schemeChanged,
                    self.colorSelResRequested,
                    self.clearHighlightsRequested,
            ):
                ss.append((signal, self._updateWorkspaceColorsIfAutoSyncing))
        return ss
    def _getCurrentScheme(self):
        combo = self.color_seq_combo
        cur_item = combo.currentItem()
        if cur_item == combo.CUSTOM:
            return self.model.custom_color_scheme
        elif cur_item == combo.DEFINE_CUSTOM:
            return combo.DEFINE_CUSTOM
        else:
            scheme = cur_item()
            return scheme
    def _setCurrentScheme(self, scheme):
        combo = self.color_seq_combo
        if scheme.custom:
            # This setCustomSchemeVisible may be necessary when we're in the
            # process of loading a new OptionsModel, since we don't know what
            # order seq_color_scheme and custom_color_scheme will be loaded in.
            combo.setCustomSchemeVisible(True)
            combo.setCurrentItem(combo.CUSTOM)
        else:
            combo.setCurrentItem(type(scheme))
    def _onCustomColorSchemeChanged(self):
        visible = self.model.custom_color_scheme is not None
        self.color_seq_combo.setCustomSchemeVisible(visible)
    @QtCore.pyqtSlot()
    def _onColorOptionChanged(self):
        """
        When any color options are changed, automatically enable colors and
        update reset
        """
        self.model.colors_enabled = True
        self.updateResetColorLabel()
    def _resetColorOptions(self):
        self.resetMappedParams()
        self.updateResetColorLabel()
    def _mappedParamsAreDefault(self):
        if self.model is None:
            return True
        for param in self.mappedParams():
            v = param.getParamValue(self.model)
            dv = param.defaultValue()
            if v != dv:
                return False
        return True
    def _createColorBtnWidget(self, colors, custom=False):
        layout = QtWidgets.QHBoxLayout()
        layout.setContentsMargins(10, 8, 10, 8)
        layout.setSpacing(0)
        for rgb_color in colors.values():
            pixmap = self.createColorPixmap(rgb_color)
            color_btn = self._createColorBtnFromPixmap(pixmap)
            # TODO MSV-1438 need different signal for outline
            color_btn.clicked.connect(
                partial(self.colorSelResRequested.emit, rgb_color))
            layout.addWidget(color_btn)
        if custom:
            custom_color_btn = QtWidgets.QToolButton()
            icon = QtGui.QIcon(":/msv/icons/ellipsis.png")
            custom_color_btn.setIcon(icon)
            custom_color_btn.clicked.connect(self.onCustomColorClicked)
            custom_color_btn.setToolTip(
                "Select custom color for selected residues")
            layout.addWidget(custom_color_btn)
        layout.addSpacing(10)
        clear_color_btn = self._createClearColorBtn()
        clear_color_btn.setToolTip("Clear highlight from selected residues")
        layout.addWidget(clear_color_btn)
        widget = QtWidgets.QWidget()
        widget.setStyleSheet(stylesheets.COLOR_BUTTON_STYLE)
        widget.setLayout(layout)
        return widget
[docs]    @classmethod
    def createColorPixmap(cls, rgb_color):
        pixmap = QtGui.QPixmap(cls.COLOR_PIXMAP_SIZE, cls.COLOR_PIXMAP_SIZE)
        pixmap.fill(QtGui.QColor(*rgb_color))
        return pixmap
    def _createColorBtnFromPixmap(self, pixmap):
        icon = QtGui.QIcon(pixmap)
        color_btn = QtWidgets.QToolButton()
        color_btn.setIcon(icon)
        return color_btn
    def _createClearColorBtn(self):
        image = QtGui.QImage(self.COLOR_IMAGE_SIZE, self.COLOR_IMAGE_SIZE,
                             QtGui.QImage.Format_RGB32)
        image.fill(Qt.white)
        painter = QtGui.QPainter()
        painter.begin(image)
        painter.setRenderHint(QtGui.QPainter.Antialiasing)
        pen = QtGui.QPen(QtGui.QColor(186, 41, 30))
        pen.setWidth(self.COLOR_IMAGE_SIZE // 8)
        painter.setPen(pen)
        painter.drawLine(self.COLOR_IMAGE_SIZE, 0, 0, self.COLOR_IMAGE_SIZE)
        painter.end()
        pixmap = QtGui.QPixmap.fromImage(image)
        color_btn = self._createColorBtnFromPixmap(pixmap)
        color_btn.clicked.connect(partial(self.colorSelResRequested.emit, ()))
        return color_btn
[docs]    def onCustomColorClicked(self):
        """
        Show a color dialog and emit the colorSelResRequested signal with the
        selected custom color.
        """
        dlg = QtWidgets.QColorDialog(self)
        if dlg.exec_():
            color = dlg.currentColor()
            rgb = tuple(color.getRgb()[:3])
            self.colorSelResRequested.emit(rgb)
[docs]    def setEnableColorPicker(self, enable):
        """
        Enables picking colors for selected residues
        :param enable: Whether to enable color picking
        :type  enable: bool
        """
        self.paint_widget.setEnabled(enable)
[docs]    def setClearHighlightStatus(self, status):
        """
        Set the dynamic property "highlight" on the "Clear All Highlights"
        button
        :param status: Value to set for "highlight"
        :type  status: bool
        """
        widget = self.clear_highlight_lbl
        widget.setProperty("highlight", status)
        qt_utils.update_widget_style(widget)
[docs]    def updateResetColorLabel(self):
        widget = self.reset_colors_lbl
        mapped_params_are_default = self._mappedParamsAreDefault()
        widget.setProperty("highlight", not mapped_params_are_default)
        qt_utils.update_widget_style(widget)
    def _requestColorWorkspace(self):
        """
        Request applying colors to the workspace
        """
        color_all = self.entire_cb.isChecked()
        self.applyColorsToWorkspaceRequested.emit(color_all)
    def _updateColorWorkspaceButton(self, entire_residues):
        """
        Update the text of the color workspace button
        """
        text = self.COLOR_WS_RES_TEXT if entire_residues else self.COLOR_WS_TEXT
        self.color_ws_btn.setText(text)
    def _updateWorkspaceColorsIfAutoSyncing(self):
        if not self.model.ws_color_sync:
            return
        self._requestColorWorkspace()
    def _setNotImplemented(self, w, disable=True):
        if disable:
            w.setEnabled(False)
        w.setToolTip("Not implemented")
[docs]class ColorSchemeInfoPopUp(mappers.TargetMixin, widgetmixins.InitMixin,
                           pop_up_widgets.PopUp):
[docs]    def initSetOptions(self):
        super().initSetOptions()
        self.setObjectName("color_scheme_info_popup")
        self.setStyleSheet("""
QFrame#color_scheme_info_popup {
    background-color: #343434;
    border: 1px #444444;
    margin: 2px;
}
QFrame#color_scheme_info_popup QLabel {
    color: white;
}
""")
[docs]    def initLayOut(self):
        super().initLayOut()
        self._grid_layout = QtWidgets.QGridLayout()
        self.main_layout.addLayout(self._grid_layout)
    def _updateDisplay(self, scheme):
        desc_dict = scheme.getSchemeSummary()
        self._clearDisplay()
        if isinstance(scheme, AbstractResiduePropertyScheme):
            top_texts = [scheme._seq_prop.display_name]
        else:
            top_texts = [scheme.NAME]
        if scheme.custom:
            top_texts.append("(custom)")
        for row_idx, text in enumerate(top_texts):
            lbl = QtWidgets.QLabel(text)
            self._grid_layout.addWidget(lbl, row_idx, 0, 1, 2)
        offset = len(top_texts)
        for idx, (color_tuple, description) in enumerate(desc_dict.items()):
            pixmap = ColorPopUp.createColorPixmap(color_tuple)
            color_lbl = QtWidgets.QLabel()
            color_lbl.setPixmap(pixmap)
            desc_lbl = QtWidgets.QLabel(description)
            row_idx = idx + offset
            self._grid_layout.addWidget(color_lbl, row_idx, 0)
            self._grid_layout.addWidget(desc_lbl, row_idx, 1)
    def _clearDisplay(self):
        while self._grid_layout.count():
            widget_item = self._grid_layout.takeAt(0)
            widget = widget_item.widget()
            widget.deleteLater()
[docs]class OtherTasksPopUp(BaseMsvPopUp):
    """
    A popup dialog to provide other task options.
    :ivar runPredictions: Signal emitted when the corresponding task is
        selected.
    :vartype runPredictions: `QtCore.pyqtSignal`
    :ivar secondaryStructure: Signal emitted when the corresponding task is
        selected.
    :vartype secondaryStructure: `QtCore.pyqtSignal`
    :ivar solventAccessibility: Signal emitted when the corresponding task is
        selected.
    :vartype solventAccessibility: `QtCore.pyqtSignal`
    :ivar domainArrangement: Signal emitted when the corresponding task is
        selected.
    :vartype domainArrangement: `QtCore.pyqtSignal`
    :ivar disorderedRegions: Signal emitted when the corresponding task is
        selected.
    :vartype disorderedRegions: `QtCore.pyqtSignal`
    :ivar disulfideBridges: Signal emitted when the corresponding task is
        selected.
    :vartype disulfideBridges: `QtCore.pyqtSignal`
    :ivar findHomologs: Signal emitted when the corresponding task is
        selected.
    :vartype findHomologs: `QtCore.pyqtSignal`
    :ivar homologResults: Signal emitted when the corresponding task is
        selected.
    :vartype homologResults: `QtCore.pyqtSignal`
    :ivar findFamily: Signal emitted when the corresponding task is
        selected.
    :vartype findFamily: `QtCore.pyqtSignal`
    :ivar buildHomologyModel: Signal emitted when the corresponding task is
        selected.
    :vartype buildHomologyModel: `QtCore.pyqtSignal`
    :ivar analyzeBindingSite: Signal emitted when the corresponding task is
        selected.
    :vartype analyzeBindingSite: `QtCore.pyqtSignal`
    :ivar compareSequences: Signal emitted when the corresponding task is
        selected.
    :vartype compareSequences: `QtCore.pyqtSignal`
    :ivar computeSequenceDescriptors: Signal emitted when the corresponding task is
        selected.
    :vartype computeSequenceDescriptors: `QtCore.pyqtSignal`
    """
    model_class = gui_models.PageModel
    _SEPARATOR = object()
    LINE_SEPARATOR_WIDTH = 175
    LINE_SEPARATOR_HEIGHT = 2
    TASK_LAYOUT_CONTENT_MARGINS = (10, 5, 10, 0)
    TASK_LAYOUT_SPACING = 20
    SUB_TASK_LAYOUT_CONTENT_MARGINS = (10, 10, 10, 10)
    SUB_TASK_LAYOUT_SPACING = 8
    SUB_TASK_LAYOUT_SEPARATOR_SPACING = 2
    runPredictions = QtCore.pyqtSignal()
    secondaryStructure = QtCore.pyqtSignal()
    solventAccessibility = QtCore.pyqtSignal()
    domainArrangement = QtCore.pyqtSignal()
    disorderedRegions = QtCore.pyqtSignal()
    disulfideBonds = QtCore.pyqtSignal()
    findHomologs = QtCore.pyqtSignal()
    homologResults = QtCore.pyqtSignal()
    findFamily = QtCore.pyqtSignal()
    buildHomologyModel = QtCore.pyqtSignal()
    analyzeBindingSite = QtCore.pyqtSignal()
    copySeqsToNewTab = QtCore.pyqtSignal(list)
    computeSequenceDescriptors = QtCore.pyqtSignal()
    BLAST_RESULTS_TEXT = "Homologs Search Results..."
[docs]    def __init__(self, parent=None):
        """
        See parent class for more information.
        """
        super().__init__(parent)
        self.setObjectName('other_tasks_pop_up')
        self.setStyleSheet(stylesheets.OTHER_TASKS_POP_UP_DIALOG)
[docs]    def defineMappings(self):
        M = self.model_class
        analyze_dialog_spec = mappers.TargetSpec(
            setter=self._analyze_dialog.updateDialog)
        return [
            (analyze_dialog_spec, M),
            (self._updateCompareSeqsDialog, M),
        ]  # yapf: disable
[docs]    def getSignalsAndSlots(self, model):
        return [
            (model.options.include_gapsChanged, self._updateCompareSeqsDialog),
            (model.alnChanged, self._updateCompareSeqsDialog),
        ]  # yapf: disable
    def _getStructureAndPropertyPredictionsMapping(self):
        return [
            ('Run All Predictions', self.runPredictions),
            self._SEPARATOR,
            ('Secondary Structure', self.secondaryStructure),
            ('Solvent Accessibility', self.solventAccessibility),
            ('Domain Arrangement', self.domainArrangement),
            ('Disordered Regions', self.disorderedRegions),
            ('Disulfide Bonds', self.disulfideBonds),
        ]
    def _getHomologyModelingAndAnalysisMapping(self):
        return [
            ('Find Homologs (BLAST)...', self.findHomologs),
            (self.BLAST_RESULTS_TEXT, self.homologResults),
            ('Find Family (Pfam)', self.findFamily),
            self._SEPARATOR,
            ('Build Homology Model...', self.buildHomologyModel),
            self._SEPARATOR,
            ('Analyze Binding Sites...', self._openAnalyzeBindingSitesDialog),
            ('Compare Sequences...', self.openCompareSequencesDialog),
        ]
    def _getPropertyCalculationsMapping(self):
        return [
            ('Compute Sequence Descriptors...', self.computeSequenceDescriptors),
             ('Aggregate Residue Property...',
             self._openAggregateResiduePropertyDialog),
        ]  # yapf:disable
    def _openAggregateResiduePropertyDialog(self):
        """
        Open the Aggregate Residue Property dialog.
        """
        dlg = dialogs.AggregatePropertyDialog(self.model.aln)
        dlg.seqDescriptorsUpdated.connect(self._onSeqPropsUpdated)
        dlg.run(modal=True)
    def _onSeqPropsUpdated(self, seq_prop):
        """
        Update the 'sequence_proeprties' list with the newly calculated (
        aggregate of residue property) property. If the property is already
        in the list, remove it first in order to include the value for new
        sequences,if any.
        :param seq_prop: The new sequence property calculated.
        :type seq_prop: properties.SequenceProperty
        """
        try:
            self.model.options.sequence_properties.remove(seq_prop)
        except ValueError:
            pass
        self.model.options.sequence_properties.append(seq_prop)
    def _setupWidgets(self):
        """
        See parent class for more information.
        """
        self._analyze_dialog = dialogs.AnalyzeBindingSiteDialog(parent=self)
        self._compare_seqs_dlg = None
        # main horizontal layout for tasks
        tasks_layout = self.createHorizontalLayout()
        tasks_layout.setContentsMargins(*self.TASK_LAYOUT_CONTENT_MARGINS)
        tasks_layout.setSpacing(self.TASK_LAYOUT_SPACING)
        tasks_layout.setAlignment(Qt.AlignTop)
        # Left list of tasks
        left_layout = self.createVerticalLayout(indent=False)
        left_layout.setSpacing(0)
        left_layout.setAlignment(Qt.AlignTop)
        tasks = self._getStructureAndPropertyPredictionsMapping()
        left_layout = self._makeTaskSection(
            'Structure and Property Predictions', tasks, left_layout)
        tasks = self._getPropertyCalculationsMapping()
        left_layout = self._makeTaskSection('Property Calculations', tasks,
                                            left_layout)
        tasks_layout.addLayout(left_layout)
        # Right lsit of tasks
        right_layout = self.createVerticalLayout(indent=False)
        right_layout.setSpacing(0)
        right_layout.setAlignment(Qt.AlignTop)
        tasks = self._getHomologyModelingAndAnalysisMapping()
        right_layout = self._makeTaskSection('Homology Modeling and Analysis',
                                             tasks, right_layout)
        tasks_layout.addLayout(right_layout)
        self.main_layout.addLayout(tasks_layout)
    def _makeTaskSection(self, task_header, tasks_mapping, layout):
        """
        Create a section of tasks, each represented by a tuple with a string and
        a slot that the label should trigger.
        :param task_header: The header for this section of tasks
        :type  task_header: str
        :param tasks_mapping: List of tuples, or self._SEPARATOR to represent
            that a line should be used to separate two items. Tuples should
            consist of the string to use for the label and a slot that should
            be called when the label is clicked.
        :type  tasks_mapping: list[tuple or self._SEPARATOR]
        :param layout: The layout to insert the new task section into.
        :type  layout: QtWidgets.QLayout
        """
        header = QtWidgets.QLabel(task_header)
        # A strong focus object is required for the popup dialog to
        # behave correctly, see note in parent class.
        header.setFocusPolicy(Qt.StrongFocus)
        header.setObjectName('other_tasks_header_lbl')
        layout.addWidget(header)
        # sub tasks_mapping sub layout
        sub_tasks_layout = self.createVerticalLayout()
        sub_tasks_layout.setContentsMargins(
            *self.SUB_TASK_LAYOUT_CONTENT_MARGINS)
        sub_tasks_layout.setSpacing(self.SUB_TASK_LAYOUT_SPACING)
        for sub_task in tasks_mapping:
            if sub_task is self._SEPARATOR:
                # add spacing after the separator
                sub_tasks_layout.addWidget(self._makeSeparator())
                sub_tasks_layout.addSpacing(
                    self.SUB_TASK_LAYOUT_SEPARATOR_SPACING)
                continue
            sub_task_name, sub_task_slot = sub_task
            sub_task_label = ClickableLabel(sub_task_name)
            sub_task_label.setObjectName('other_tasks_sub_task_lbl')
            sub_task_label.clicked.connect(self.close)
            sub_task_label.clicked.connect(sub_task_slot)
            if sub_task_name == self.BLAST_RESULTS_TEXT:
                sub_task_label.setToolTip("Displays a BLAST results dialog")
            sub_tasks_layout.addWidget(sub_task_label)
        layout.addLayout(sub_tasks_layout)
        return layout
    def _makeSeparator(self):
        """
        Helper method to create a separator widget.
        """
        line_separator = QtWidgets.QFrame()
        line_separator.setObjectName('other_tasks_separator')
        line_separator.setFrameShape(QtWidgets.QFrame.HLine)
        line_separator.setLineWidth(self.LINE_SEPARATOR_HEIGHT)
        line_separator.setFixedWidth(self.LINE_SEPARATOR_WIDTH)
        return line_separator
[docs]    def openCompareSequencesDialog(self):
        aln = self.model.aln
        if self._compare_seqs_dlg is None:
            dialog = dialogs.CompareSeqsDialog(aln, parent=self.parent())
            dialog.copySeqsToNewTab.connect(self.copySeqsToNewTab)
            self._compare_seqs_dlg = dialog
        self._compare_seqs_dlg.run()
        return self._compare_seqs_dlg
    def _updateCompareSeqsDialog(self):
        if self._compare_seqs_dlg is not None:
            self._compare_seqs_dlg.update(self.model.aln,
                                          self.model.options.include_gaps)
    def _openAnalyzeBindingSitesDialog(self):
        aln = self.model.aln
        has_hidden_seqs = dialogs.prompt_for_hidden_seqs(self, aln)
        if has_hidden_seqs:
            return
        self._analyze_dialog.run()
[docs]class PopUpDialogButton(pop_up_widgets.ToolButtonWithPopUp):
    """
    A checkable button that brings up a popup dialog when toggled.
    """
[docs]    def __init__(self, parent, pop_up_class, text):
        super().__init__(parent, pop_up_class, text=text)
        self.setFocusPolicy(QtCore.Qt.ClickFocus)
        self.setPopupHalign(self.ALIGN_LEFT)
        self.setPopupValign(self.ALIGN_TOP)
    @property
    def popup_dialog(self):
        return self._pop_up
[docs]class PopUpDialogButtonWithIndicator(PopUpDialogButton):
    """
    A popup button that draws the menu indicator, which adds an arrow to the
    toolbutton
    """
    INDICATOR_WIDTH = 8
[docs]    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self.indicator = QtGui.QPixmap(
            ':/msv/icons/arrow-open.png').scaledToWidth(
                self.INDICATOR_WIDTH, QtCore.Qt.SmoothTransformation)
[docs]    def sizeHint(self):
        """
        Make some extra room for the indicator arrow
        """
        return super().sizeHint() + QtCore.QSize(self.indicator.width(), 0)
[docs]    def paintEvent(self, ev):
        opt = QtWidgets.QStyleOptionToolButton()
        self.initStyleOption(opt)
        painter = QtWidgets.QStylePainter(self)
        painter.drawComplexControl(QtWidgets.QStyle.CC_ToolButton, opt)
        target = QtCore.QPoint()
        # Give indicator a bit of padding to the right
        padding_right = 8
        target.setX(opt.rect.width() - self.indicator.width() - padding_right)
        # Put the indicator in the center, shifted down a bit
        target.setY((opt.rect.height() - self.indicator.height()) // 2 + 1)
        painter.drawPixmap(target, self.indicator)
[docs]class EllipsisPopUpDialogToolButton(QtWidgets.QToolButton):
    """
    An ellipsis options tool button is a checkable widget, which shows an
    ellipsis button on top to indicate that a pop-up dialog is available
    whenever the user hovers over the widget.
    """
    POPUP_VISIBLE_PROPERTY = 'popup_visible'
[docs]    def __init__(self, pop_up_class, parent=None):
        """
        Sets up the tool button and the ellipsis button on top.
        :param pop_up_class: The class of the pop up widget to show when the
            ellipsis button is clicked or the checkable tool button is
            right-clicked.
        :type pop_up_class: subclass of `PopUp`
        See parent `QtWidgets.QToolButton` for further documentation.
        """
        super().__init__(parent)
        self.setCheckable(True)
        # ellipsis tool button that opens a popup dialog
        self.ellipsis_btn = _EllipsisPopUpButton(self, pop_up_class)
        self.ellipsis_btn.popUpClosing.connect(self.onPopUpClosed)
        # the ellipsis button is only shown during hover events or when the
        # popup dialog is open
        self._updateWidgetStyle(False)
        self.setStyleSheet(stylesheets.ELLIPSIS_POP_UP_DIALOG_TOOL_BUTTON)
    @property
    def popup_dialog(self):
        return self.ellipsis_btn.popup_dialog
[docs]    def showPopUpDialog(self):
        """
        Shows the popup dialog.
        """
        self._updateWidgetStyle(True)
        self.ellipsis_btn.click()
[docs]    def onPopUpClosed(self):
        """
        Update the widget style once the popup closes, only if the user is not
        actively hovering over the widget.
        """
        mouse_pos = QtGui.QCursor.pos()
        mouse_pos = self.mapFromGlobal(mouse_pos)
        if not self.rect().contains(mouse_pos):
            self._updateWidgetStyle(False)
[docs]    def enterEvent(self, event):
        # See parent `QtWidgets.QToolButton` for documentation
        self._updateWidgetStyle(True)
        super().enterEvent(event)
[docs]    def leaveEvent(self, event):
        # See parent `QtWidgets.QToolButton` for documentation
        e_btn = self.ellipsis_btn
        mouse_pos = e_btn.mapFromGlobal(QtGui.QCursor.pos())
        mouse_over_e_btn = e_btn.rect().contains(mouse_pos)
        should_hide = not (e_btn.isChecked() or mouse_over_e_btn)
        # Update the widget style for a mouse leave event only if the popup
        # dialog is not currently open and the mouse is not over the ellipsis
        # button.
        if should_hide:
            self._updateWidgetStyle(False)
        super().leaveEvent(event)
[docs]    def mousePressEvent(self, event):
        # See parent `QtWidgets.QToolButton` for documentation
        # Open the popup dialog if the main tool button is right-clicked
        if event.button() == QtCore.Qt.RightButton:
            self.showPopUpDialog()
        else:
            super().mousePressEvent(event)
    def _updateWidgetStyle(self, state):
        """
        Update the widget style according to given state. This updates the
        ellipsis button to be visible or hidden, and sets the hover style of
        the tool button while the popup dialog is open.
        :param state: whether the styling should be applied or not.
        :type state: bool
        """
        if state:
            self.ellipsis_btn.show()
        else:
            self.ellipsis_btn.hide()
        self.setProperty(self.POPUP_VISIBLE_PROPERTY, state)
        qt_utils.update_widget_style(self)
class _EllipsisPopUpButton(PopUpDialogButton):
    """
    :cvar BUTTON_OFFSET: Vertical overlap between the ellipsis button and the
        tool button
    :vartype BUTTON_OFFSET: int
    """
    BUTTON_OFFSET = 5
    def __init__(self, parent, pop_up_class):
        """
        :param parent: Toolbutton to show ellipsis button over
        :type parent: EllipsisPopUpDialogToolButton
        :param pop_up_class: The class of pop up widget to show when the
            ellipsis button is clicked
        :type pop_up_class: PopUp
        """
        super().__init__(parent, pop_up_class, text=None)
        self.setObjectName('ellipsis_btn')
        self.setArrowType(QtCore.Qt.NoArrow)
        self.setWindowFlags(Qt.FramelessWindowHint | Qt.Tool |
                            Qt.WindowStaysOnTopHint)
        if sys.platform == 'darwin':
            self.popup_dialog.visibilityChanged.connect(
                self._macActiveWindowWorkaround)
    def show(self):
        """
        Show the ellipsis button if neither it nor the popup widget are visible
        """
        if not self.isVisible() and not self.popup_dialog.isVisible():
            self._moveAboveParent()
            super().show()
    def leaveEvent(self, event):
        """
        Hide the ellipsis button on mouse leave if the mouse isn't over the
        tool button
        """
        btn = self.parent()
        mouse_pos = btn.mapFromGlobal(QtGui.QCursor.pos())
        mouse_over_btn = btn.rect().contains(mouse_pos)
        if not mouse_over_btn:
            self.hide()
        super().leaveEvent(event)
    def _moveAboveParent(self):
        """
        Move the ellipsis button above and slightly overlapping the tool button
        """
        self.adjustSize()  # Force btn to update height
        e_height = self.height()
        btn = self.parent()
        btn_pos = btn.mapToGlobal(btn.rect().topLeft())
        x, y = btn_pos.x(), btn_pos.y()
        ellipsis_pos = QtCore.QPoint(x, y - e_height + self.BUTTON_OFFSET)
        self.move(ellipsis_pos)
    if sys.platform == 'darwin':
        def _macActiveWindowWorkaround(self, visible):
            """
            On Mac, the main window does not become active if the popup gets
            hidden while the ellipsis button is hidden. If the main window is
            not active, its tooltips don't work. This explicitly activates the
            main window whenever the popup gets hidden.
            """
            if not visible:
                app = QtWidgets.QApplication.instance()
                app.setActiveWindow(self.popup_dialog.parent())
[docs]def make_pop_up_tool_button(parent,
                            pop_up_class,
                            tooltip="",
                            obj_name="",
                            text="",
                            icon="",
                            indicator=False):
    """
    Helper function to setup a pop-up
    QToolButton (popups.PopUpDialogButton).
    :param parent: the parent for this button. This is very important to set,
        as the pop-up will require correct parentage to propagate the pop-up
        dialog all the way to the top to display properly.
    :type parent: `QtWidgets.QWidget`
    :param pop_up_class: the class type of the popup.
    :type pop_up_class: subclass of `pop_up_widgets.PopUp`
    :param tooltip: tooltip the toolbutton should display.
    :type tooltip: str
    :param obj_name: object name for access from style sheet.
    :type obj_name: str
    :param text: to be shown beside the icon.
    :type text: str
    :param icon: the filename of the icon, which should live in msv/icons.
    :type icon: str
    :param bool indicator: Whether to draw a menu indicator. Only recommended if
        text is set (otherwise arrow will appear over the icon).
    :return: the created toolbutton with the given args.
    :rtype: `popups.PopUpDialogButton`
    """
    BtnCls = PopUpDialogButtonWithIndicator if indicator else PopUpDialogButton
    btn = BtnCls(parent=parent, pop_up_class=pop_up_class, text=text)
    btn.setToolTip(tooltip)
    btn.setObjectName(obj_name)
    # Hide built-in indicator arrow
    btn.setArrowType(QtCore.Qt.NoArrow)
    if icon:
        q_icon = QtGui.QIcon(ICONS_PATH + icon)
        btn.setIcon(q_icon)
    return btn