Source code for schrodinger.ui.qt.pop_up_widgets

"""
Widgets for creating a line edit with a pop up editor.  To create a new pop up
editor:

- Create a subclass of `PopUp`.  In `setup()`, define and layout the desired
  widgets.
- Instantiate `LineEditWithPopUp` for a stand-alone line edit or
  `PopUpDelegate` for a table delegate. `__init__` takes the `PopUp` subclass
  as an argument.
"""

import enum
import sys
import weakref

import schrodinger.ui.qt.utils as qt_utils
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.standard.icons import icons
from schrodinger.ui.qt.standard_widgets import hyperlink

PopUpAlignment = enum.Enum("PopUpAlignment", ["Center", "Right"])
(REJECT, ACCEPT, ACCEPT_MULTI, UNKNOWN) = list(range(4))
"""
Constants representing what event triggered the closing of a popup. These enums
are emitted by the popUpClosing signal.
- REJECT if the user closed the pop up by pressing Esc
- ACCEPT if the user closed the pop up by hitting Enter or by shifting
  focus
- ACCEPT_MULTI if the user closed the pop up by hitting Ctrl+Enter
- UNKNOWN if the event that closed the widget is not known (this happens
    when we use the Qt.PopUp window flag to do the popup closing for us)
"""


class PopUp(QtWidgets.QFrame):
    """
    The base class for pop up widgets. This class is not intended to
    be instantiated directly and should be subclassed.  Subclasses must
    implement setup(). Subclasses may also emit dataChanged and popUpResized when
    appropriate.

    Important Note: at least one strong focus widget should be part of the
    popup dialog, otherwise a widget's focus policy should be set to
    `Qt.StrongFocus` in order for the popup closing behavior to execute
    properly.

    :cvar dataChanged: A signal emitted when a change in the pop up should
        trigger a change in the contents of the line edit.  This signal is emitted
        with the desired contents of the line edit (str).
    :vartype dataChanged: `PyQt5.QtCore.pyqtSignal`

    :cvar popUpResized: A signal emitted when the size of the pop up changes.
        The line edit will respond by repositioning the pop up.
    :vartype popUpResized: `PyQt5.QtCore.pyqtSignal`

    :cvar visibilityChanged: A signal emitted when the pop up is shown or
        hidden. Includes whether the pop up is now visible (`True`) or not
        (`False`).
    :vartype visibilityChanged: QtCore.pyqtSignal
    """

    dataChanged = QtCore.pyqtSignal(str)
    popUpResized = QtCore.pyqtSignal()
    visibilityChanged = QtCore.pyqtSignal(bool)

    def __init__(self, parent):
        super(PopUp, self).__init__(parent)
        self.setAutoFillBackground(True)
        self.setFrameShape(self.StyledPanel)
        self.setFrameShadow(self.Plain)
        self.setup()

        # On Linux and Windows, showing a popup triggers a focus event
        # targeting one of the popup's subwidgets. This is not the case
        # for OSX, so this mimics that behavior explicitly. See MSV-1408.
        if sys.platform == 'darwin':
            firstFocusableChild = None
            for child in self.children():
                if (isinstance(child, QtWidgets.QWidget) and
                        child.focusPolicy() != Qt.NoFocus):
                    firstFocusableChild = child
                    break
            if firstFocusableChild:
                self.setFocusProxy(firstFocusableChild)

    def closeEvent(self, event):
        self.visibilityChanged.emit(False)
        return super().closeEvent(event)

    def setup(self):
        """
        Subclass-specific initialization.  Subclasses must implement this
        function.
        """

        raise NotImplementedError

    def show(self):
        super(PopUp, self).show()
        self.raise_()

        # The popup doesn't take focus by default on OS X.
        # See comment in __init__.
        if sys.platform == 'darwin':
            self.setFocus()

    def installPopUpEventFilter(self, event_filter):
        """
        Install the provided event filter on all widgets within this pop up that
        can receive focus.

        :note: This function only installs the event filter on immediate
            children of this widget.  As a result, keyboard events on grandchildren
            (or later descendant) widgets will not be handled properly.  If this
            causes issues, the implementation will have to be modified.

        :param event_filter: The event filter to install
        :type event_filter: `_BasisSelectorPopUpEventFilter`
        """

        for cur_widget in self.children():
            if (isinstance(cur_widget, QtWidgets.QWidget) and
                    cur_widget.focusPolicy() != Qt.NoFocus):
                cur_widget.installEventFilter(event_filter)

    def subWidgetHasFocus(self):
        """
        Return True if any widget within the pop up has focus.  False
        otherwise.

        :note: Note that combo boxes have various list view and frame children
            that can receive focus (and which of these widgets can receive focus
            varies by OS).  As a result, we check the full ancestry of the focus
            widget here rather than just checking it's parent. Also note that we
            can't use Qt's isAncestorOf() function to check ancestry, since combo
            box drop downs are considered to be their own window and isAncestorOf()
            requires ancestors to be part of the same window.
        """

        focus_widget_ancestor = QtWidgets.QApplication.focusWidget()
        while focus_widget_ancestor is not None:
            if focus_widget_ancestor is self:
                return True
            try:
                focus_widget_ancestor = focus_widget_ancestor.parent()
            except TypeError:
                # Some widgets, such as InputSelector, override the parent
                # method with a parent attribute pointing to a widget. This
                # causes a TypeError when trying to call that method.
                if hasattr(focus_widget_ancestor, 'parent'):
                    focus_widget_ancestor = focus_widget_ancestor.parent
                else:
                    raise
        return False

    def estimateMaxHeight(self):
        """
        Return an estimate of the maximum allowable height of this pop up.  This
        estimate is used to ensure that the pop up is positioned within the
        window.  The default implementation uses the current size hint.
        Subclasses can reimplement this function if they can calculate a more
        accurate allowable height.  This is typically only applicable if the pop up
        is likely to change size.

        :return: The maximum allowable height
        :rtype: int
        """

        return self.sizeHint().height()

    def estimateMaxWidth(self):
        """
        Return an estimate of the maximum allowable width of this pop up.  This
        estimate is used to ensure that the pop up is positioned within the
        window.  The default implementation uses the current size hint.
        Subclasses can reimplement this function if they can calculate a more
        accurate allowable width.  This is typically only applicable if the pop up
        is likely to change size.

        :return: The maximum allowable width
        :rtype: int
        """

        return self.sizeHint().width()

    def lineEditUpdated(self, text):
        """
        Update this pop up in response to the user changing the line edit text.
        Note that, by default, this widget will not be able to send signals
        during execution of this method.  This prevents an infinite loop of
        `PopUp.lineEditUpdated` and `LineEditWithPopUp.popUpUpdated`.  To
        modify this behavior, subclass `LineEditWithPopUp` and reimplement
        `LineEditWithPopUp.popUpUpdated`.

        :param text: The current text of the line edit
        :type text: str
        """

        # This method intentionally left blank

    def showEvent(self, event):
        """
        Emit a signal every time this pop up is shown.
        """

        super().showEvent(event)
        self.visibilityChanged.emit(True)

    def hideEvent(self, event):
        """
        Emit a signal every time this pop up is hidden.
        """

        super().hideEvent(event)
        self.visibilityChanged.emit(False)


class _AbstractPopUpEventFilter(QtCore.QObject):
    """
    An event filter that will hide or resize the `PopUp` when appropriate.
    """

    def __init__(self, parent):
        """
        :param parent: The widget with a pop up
        :type parent: `_WidgetWithPopUpMixin`
        """

        super(_AbstractPopUpEventFilter, self).__init__(parent)
        # use a weakref to avoid a circular reference, since the widget will
        # hold a reference to this object
        self._widget = weakref.proxy(parent)
        self._pop_up = weakref.proxy(parent._pop_up)


class _LostFocusEventFilter(_AbstractPopUpEventFilter):

    def eventFilter(self, obj, event):
        """
        Hide the pop up if it and the widget have lost focus or if the user
        hits enter or escape.  If the pop up is closed, the popUpClosed signal
        is emitted with a constant representing how the popup was closed.
        See the docstrings at the top of pop_up_widgets for more information.

        - REJECT if the user closed the pop up by pressing Esc
        - ACCEPT if the user closed the pop up by hitting Enter or by shifting
          focus
        - ACCEPT_MULTI if the user closed the pop up by hitting Ctrl+Enter
        - UNKNOWN if the event that closed the widget is not known (this happens
            when we use the Qt.PopUp window flag to do the popup closing for us)
        """

        lost_focus = (
            event.type() == event.FocusOut and
            not (self._widget.hasFocus() or self._pop_up.subWidgetHasFocus()))
        hide = event.type() == event.Hide and obj is self._widget
        if hide or lost_focus:
            self._pop_up.hide()
            self._widget.popUpClosing.emit(ACCEPT)
            # Don't return True, since other objects may also want to handle
            # this event

        elif (event.type() == event.KeyPress and
              event.key() in (Qt.Key_Return, Qt.Key_Enter, Qt.Key_Escape) and
              not isinstance(obj, QtWidgets.QListView)):
            # If the user hit enter or escape.  Note that we ignore key
            # presses on the combo box list views, since that are used
            # to select an entry from the list

            if event.key() == Qt.Key_Escape:
                emit_with = REJECT
            elif event.modifiers() & Qt.ControlModifier:
                emit_with = ACCEPT_MULTI
            else:
                emit_with = ACCEPT

            self._widget.setFocus()
            self._pop_up.hide()
            self._widget.popUpClosing.emit(emit_with)
            return True

        return False


class _WindowEventFilter(_AbstractPopUpEventFilter):

    def eventFilter(self, obj, event):
        """
        Hide the pop up if the user clicks away from it.  When the pop up is
        closed, the pop_up_closed signal is emitted with ACCEPT.  Also
        recalculate the pop up's position if the window is resized.

        Note: MousePressEvents will be swallowed by widgets with viewports.
        The _LostFocusEventFilter should catch these occurences and close
        the popup anyways.

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

        if event.type() == event.MouseButtonPress and self._pop_up.isVisible():
            global_pos = event.globalPos()
            pop_up_pos = self._pop_up.mapFromGlobal(global_pos)
            pop_up_rect = self._pop_up.contentsRect()
            line_edit_pos = self._widget.mapFromGlobal(global_pos)
            line_edit_rect = self._widget.contentsRect()
            if not (pop_up_rect.contains(pop_up_pos) or
                    line_edit_rect.contains(line_edit_pos)):
                self._widget.setFocus()
                self._pop_up.hide()
                self._widget.popUpClosing.emit(ACCEPT)
        elif event.type() == event.Resize:
            # If the window is resized, it's possible that there's no longer
            # enough room for the pop up in its current position.
            self._widget._setPopUpGeometry()
        return False


class _MoveEventFilter(_AbstractPopUpEventFilter):

    def eventFilter(self, obj, event):
        """
        Recalculate the pop up's position if a parent widget moves.
        """

        if event.type() == event.Move:
            self._widget._setPopUpGeometry()
        return False


class _AbstractWidgetWithPopUpMixin:
    """
    Mixin for a widget class that should produce a popup. Includes a framework
    for setting the size and position of the popup frame. Subclasses must
    implement a `_setPopUpGeometry()` method.
    """

    ALIGN_TOP, ALIGN_BOTTOM, ALIGN_RIGHT, ALIGN_LEFT, ALIGN_AUTO = list(
        range(5))

    def __init__(self, parent, pop_up_class=None):
        super().__init__(parent)
        self._popup_halign = self.ALIGN_AUTO
        self._popup_valign = self.ALIGN_AUTO
        if pop_up_class:
            self.setPopUpClass(pop_up_class)
        else:
            self._pop_up = None

    def setPopUpClass(self, pop_up_class):
        """
        If a pop up class was not specified via the constructor, use this
        method to set it after the fact. Useful for placing widgets into
        *.ui files.
        """
        pop_up_widget = pop_up_class(self.parent().window())
        self.setPopUp(pop_up_widget)

    def setPopUp(self, pop_up):
        """
        Set the pop up widget to the specified pop up widget instance.

        :type pop_up: Instance to set as the pop up widget.
        :type pop_up: PopUp
        """
        self._pop_up = pop_up
        self._pop_up.dataChanged.connect(self.popUpUpdated)
        self._pop_up.popUpResized.connect(self._setPopUpGeometry)
        self._pop_up.hide()

    def setPopupHalign(self, popup_halign):
        """
        Specify whether the pop up should have its right edge aligned with the
        right edge of the widget (ALIGN_RIGHT), have its left edge aligned
        with the left edge of the widget (ALIGN_LEFT), or have it's
        horizontal alignment determined automatically (ALIGN_AUTO).  Note that
        this setting is moot if the widget is wider than the pop up's size
        hint, as the pop up will be extended to the same width as the widget.

        :param popup_halign: The desired horizontal alignment of the pop up.
            Must be one of ALIGN_RIGHT, ALIGN_LEFT, or ALIGN_AUTO.
        :type popup_halign: int
        """

        if popup_halign not in (self.ALIGN_LEFT, self.ALIGN_RIGHT,
                                self.ALIGN_AUTO):
            err = "Unrecognized value for popup_halign: %s" % self._popup_halign
            raise ValueError(err)
        self._popup_halign = popup_halign
        self._setPopUpGeometry()

    def setPopupValign(self, popup_valign):
        """
        Specify whether the pop up should appear above (ALIGN_TOP), below
        (ALIGN_BOTTOM) the widget, or have it's vertical alignment determined
        automatically (ALIGN_AUTO).

        :param popup_valign: The desired vertical alignment of the pop up.
            Must be either ALIGN_TOP, ALIGN_BOTTOM, or ALIGN_AUTO.
        :type popup_valign: int
        """

        if popup_valign not in (self.ALIGN_TOP, self.ALIGN_BOTTOM,
                                self.ALIGN_AUTO):
            err = "Unrecognized value for popup_valign: %s" % self._popup_halign
            raise ValueError(err)
        self._popup_valign = popup_valign
        self._setPopUpGeometry()

    def moveEvent(self, event):
        """
        Update the pop up position and size when the widget is moved
        """

        self._setPopUpGeometry()
        return super().moveEvent(event)

    def resizeEvent(self, event):
        """
        Update the pop up position and size when the widget is resized
        """

        self._setPopUpGeometry()
        return super().resizeEvent(event)

    def _setPopUpGeometry(self):
        """
        Determine the appropriate position and size for the pop up.  Note that
        the pop up will never be narrower than the widget.
        """
        raise NotImplementedError

    def popUpUpdated(self, text):
        """
        Whenever the pop up emits the dataChanged signal, update the widget.
        This function should be implemented in subclasses if required.

        :param text: The text emitted with the dataChanged signal
        :type text: str
        """


class _WidgetWithPopUpMixin(_AbstractWidgetWithPopUpMixin):
    """
    Container for methods shared between ComboBoxWithPopUp and LineEditWithPopUp
    classes.
    """

    def __init__(self, parent, pop_up_class=None):
        super().__init__(parent, pop_up_class)
        self._first_show = True

    def setPopUp(self, pop_up):
        # See super class for method documentation.
        super().setPopUp(pop_up)
        parent = self.parent()
        window = parent.window()
        self._focus_filter = _LostFocusEventFilter(self)
        self.installEventFilter(self._focus_filter)
        self._pop_up.installPopUpEventFilter(self._focus_filter)
        self._window_filter = _WindowEventFilter(self)
        window.installEventFilter(self._window_filter)

        # Install an event filter on every widget in the hierarchy up to the
        # panel so that if any of them are moved, the the pop up will be moved
        # as well.
        self._move_event_filter = _MoveEventFilter(self)
        cur_widget = parent
        while cur_widget is not None and not cur_widget.isWindow():
            cur_widget.installEventFilter(self._move_event_filter)
            cur_widget = cur_widget.parent()

    def _setPopUpGeometry(self):
        """
        Determine the appropriate position and size for the pop up.  Note that
        the pop up will never be narrower than the widget.
        """

        halign, valign = self._getPopupAlignment()
        rect = self.geometry()
        le_height = rect.height()
        le_width = rect.width()
        le_right = rect.right()

        pop_up_size = self._pop_up.sizeHint()
        pop_up_height = pop_up_size.height()
        pop_up_width = pop_up_size.width()

        rect.setHeight(pop_up_height)
        if pop_up_width > le_width:
            rect.setWidth(pop_up_width)

        if halign == self.ALIGN_RIGHT:
            new_right = rect.right()
            x_trans = le_right - new_right
        elif halign == self.ALIGN_LEFT:
            x_trans = 0

        if valign == self.ALIGN_BOTTOM:
            y_trans = le_height
        elif valign == self.ALIGN_TOP:
            y_trans = -rect.height()
        rect.translate(x_trans, y_trans)

        # translate rect so it's in the coordinate system of the pop up's parent
        # (which is the window)
        new_topleft = self.parent().mapTo(self._pop_up.parent(), rect.topLeft())
        rect.moveTopLeft(new_topleft)
        self._pop_up.setGeometry(rect)

    def _getPopupAlignment(self):
        """
        Get the horizontal and vertical alignment of the pop up.  If either
        alignment is set to ALIGN_AUTO, alignment will be determined based on
        the current widget placement in the window.

        :return: A tuple of:
              - the horizontal alignment (either ALIGN_LEFT or ALIGN_RIGHT)
              - the vertical alignement (either ALIGN_TOP or ALIGN_BOTTOM)
        :rtype: tuple
        """

        parent = self.parent()
        window = self.window()
        rect = self.geometry()
        topleft = rect.topLeft()
        bottomright = rect.bottomRight()
        if parent is not window:
            # Make sure that values are in the coordinate system of the window
            topleft = parent.mapTo(window, topleft)
            bottomright = parent.mapTo(window, bottomright)
        if self._popup_halign == self.ALIGN_AUTO:
            le_left = topleft.x()
            le_right = bottomright.x()
            pop_up_width = self._pop_up.estimateMaxWidth()
            window_width = self.window().width()
            halign = self._autoPopupAlignment(le_left, self.ALIGN_LEFT,
                                              le_right, self.ALIGN_RIGHT,
                                              pop_up_width, window_width)
        else:
            halign = self._popup_halign

        if self._popup_valign == self.ALIGN_AUTO:
            le_top = topleft.y()
            le_bottom = bottomright.y()
            pop_up_height = self._pop_up.estimateMaxHeight()
            window_height = self.window().height()
            valign = self._autoPopupAlignment(le_bottom, self.ALIGN_BOTTOM,
                                              le_top, self.ALIGN_TOP,
                                              pop_up_height, window_height)
        else:
            valign = self._popup_valign

        return halign, valign

    def _autoPopupAlignment(self, le_bottom, align_bottom, le_top, align_top,
                            max_pop_up_height, window_height):
        """
        Determine the appropriate pop up placement based on the window geometry.
        Note that variable names here refer to vertical alignment, but this
        function is also used to determine horizontal alignment (bottom -> left,
        top -> right).

        :param le_bottom: The bottom (or left) coordinate of the widget
        :type le_bottom: int

        :param align_bottom: The flag value for bottom (or left) alignment
        :type align_bottom: int

        :param le_top: The top (or right) coordinate of the widget
        :type le_top: int

        :param align_top: The flag value for top (or right) alignment
        :type align_top: int

        :param max_pop_up_height: The maximum height (or width) of the pop up
        :type max_pop_up_height: int

        :param window_height: The height (or width) of the window containing
            this widget
        :type window_height: int

        :return: The flag value for the appropriate pop up alignment
        :rtype: int
        """

        top_if_up = le_top - max_pop_up_height
        bottom_if_down = le_bottom + max_pop_up_height
        past_bottom_by = bottom_if_down - window_height
        past_top_by = -top_if_up
        if past_bottom_by < 0 or past_bottom_by < past_top_by:
            return align_bottom
        else:
            return align_top

    def showEvent(self, event):
        """
        Update the pop up position and size when the widget is shown
        """

        if self._first_show:
            # If this is the first time the widget is being shown, then it's
            # location hasn't been initialized yet.  It will think it's at
            # location (0, 0) until after the first draw.  As such we use a
            # single shot timer to wait until after the draw to position the pop
            # up.
            QtCore.QTimer.singleShot(0, self._setPopUpGeometry)
            self._first_show = False
        else:
            self._setPopUpGeometry()
        super(_WidgetWithPopUpMixin, self).showEvent(event)


class LineEditWithPopUp(_WidgetWithPopUpMixin, QtWidgets.QLineEdit):
    """
    A line edit with a pop up that appears whenever the line edit has focus.

    :ivar popUpClosing: A signal emitted when the pop up is closed.
    :vartype popUpClosing: `PyQt5.QtCore.pyqtSignal`

    The signal is emitted with:
    - REJECT if the user closed the pop up by pressing Esc
    - ACCEPT if the user closed the pop up by hitting Enter or by shifting
      focus
    - ACCEPT_MULTI if the user closed the pop up by hitting Ctrl+Enter

    :ivar _pop_up: The pop up widget
    :vartype _pop_up: `PopUp`
    """

    popUpClosing = QtCore.pyqtSignal(int)

    def __init__(self, parent, pop_up_class):
        """
        :param parent: The Qt parent widget
        :type parent: `PyQt5.QtWidgets.QWidget`

        :param pop_up_class: The class of the pop up widget.  Should be a
            subclass of `PopUp`.
        :type pop_up_class: type
        """

        super().__init__(parent, pop_up_class)
        self.textChanged.connect(self.textUpdated)
        self.textUpdated("")
        self._pop_up.hide()

    def focusInEvent(self, event):
        """
        When the line edit receives focus, show the pop up
        """

        self._pop_up.show()
        super(LineEditWithPopUp, self).focusInEvent(event)

    def mousePressEvent(self, event):
        """
        If the user clicks on the line edit and it already has focus, show the
        pop up again (in case the user hid it with a key press)
        """

        if self.hasFocus():
            self._pop_up.show()
        super(LineEditWithPopUp, self).mousePressEvent(event)

    def textUpdated(self, text):
        """
        Whenever the text in the line edit is changed, show the pop up and call
        `PopUp.lineEditUpdated`.  The default implementation prevents the
        `PopUp` from sending signals during the execution of
        `PopUp.lineEditUpdated`.  This prevents an infinite loop of
        `PopUp.lineEditUpdated` and `LineEditWithPopUp.popUpUpdated`.

        :param text: The current text in the line edit
        :type text: str
        """
        self._pop_up.show()
        with qt_utils.suppress_signals(self._pop_up):
            self._pop_up.lineEditUpdated(text)


class ComboBoxWithPopUp(_WidgetWithPopUpMixin, QtWidgets.QComboBox):
    """
    A combo box with a pop up that appears whenever the menu is pressed.

    :ivar popUpClosing: A signal emitted when the pop up is closed.
    :vartype popUpClosing: `PyQt5.QtCore.pyqtSignal`

    The signal is emitted with:

    - REJECT if the user closed the pop up by pressing Esc
    - ACCEPT if the user closed the pop up by hitting Enter or by shifting
      focus
    - ACCEPT_MULTI if the user closed the pop up by hitting Ctrl+Enter

    :ivar _pop_up: The pop up widget
    :vartype _pop_up: `PopUp`
    """

    popUpClosing = QtCore.pyqtSignal(int)

    def showPopup(self):
        if self._pop_up is not None:
            self._pop_up.show()

    def hidePopup(self):
        if self._pop_up is not None:
            self._pop_up.hide()
        super().hidePopup()


class PopUpDelegate(QtWidgets.QStyledItemDelegate):
    """
    A table delegate that uses a `LineEditWithPopUp` as an editor.

    :ivar commitDataToSelected: Commit the data from the current editor to all
        selected cells.  Only emitted if the class is instantiated with
        `enable_accept_multi=True`.  This signal (and behavior) is not a standard
        Qt behavior, so the table view must manually connect this signal and respond
        to it appropriately.  This signal is emitted with the editor, the current
        index, and the delegate.
    :vartype commitDataToSelected: `PyQt5.QtCore.pyqtSignal`
    """

    commitDataToSelected = QtCore.pyqtSignal(
        QtWidgets.QWidget, QtCore.QModelIndex, QtWidgets.QAbstractItemDelegate)

    def __init__(self, parent, pop_up_class, enable_accept_multi=False):
        """
        :param parent: The Qt parent widget
        :type parent: `PyQt5.QtWidgets.QWidget`

        :param pop_up_class: The class of the pop up widget.  Should be a
            subclass of `PopUp`.
        :type pop_up_class: type

        :param enable_accept_multi: Whether committing data to all selected
            cells at once is enabled.  If True, `commitDataToSelected` will be
            emitted when the `LineEditWithPopUp` emits `popUpClosing` with
            `ACCEPT_MULTI`.  If False, `commitData` will be emitted instead.
        :type enable_accept_multi: bool
        """

        super(PopUpDelegate, self).__init__(parent)
        self._pop_up_class = pop_up_class
        self._enable_accept_multi = enable_accept_multi

    def createEditor(self, parent, option, index):
        """
        Create the editor and connect the `popUpClosing` signal.  If a subclass
        needs to modify editor instantiation, `_createEditor` should be
        reimplemented instead of this function to ensure that the
        `popUpClosing` signal is connected properly.

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

        editor = self._createEditor(parent, option, index)
        editor.index = index
        editor.popUpClosing.connect(lambda x: self.popUpClosed(editor, x))
        return editor

    def _createEditor(self, parent, option, index):
        """
        Create and return the `LineEditWithPopUp` editor.  If a subclass needs
        to modify editor instantiation, this function should be reimplemented
        instead of `createEditor` to ensure that the `popUpClosing` signal is
        connected properly.

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

        return LineEditWithPopUp(parent, self._pop_up_class)

    def setEditorData(self, editor, index):
        # See Qt documentation
        value = index.data()
        editor.setText(value)

    def setModelData(self, editor, model, index):
        # See Qt documentation
        if editor.hasAcceptableInput():
            value = editor.text()
            model.setData(index, value)

    def eventFilter(self, editor, event):
        """
        Ignore the editor losing focus, since focus may be switching to one of
        the pop up widgets.  If the editor including the popup loses focus,
        popUpClosed will be called.

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

        if isinstance(event, QtGui.QFocusEvent) and event.lostFocus():
            return False
        else:
            return super(PopUpDelegate, self).eventFilter(editor, event)

    def popUpClosed(self, editor, accept):
        """
        Respond to the editor closing by either rejecting or accepting the data.
        If `enable_accept_multi` is True, the data may also be committed to all
        selected rows.

        :param editor: The editor that was just closed
        :type editor: `LineEditWithPopUp`

        :param accept: The signal that was emitted by the editor when it closed
        :type accept: int
        """

        if accept == ACCEPT:
            self.commitData.emit(editor)
        elif accept == ACCEPT_MULTI:
            if self._enable_accept_multi:
                self.commitDataToSelected.emit(editor, editor.index, self)
            else:
                self.commitData.emit(editor)
        self.closeEditor.emit(editor, self.NoHint)


class _AbstractButtonWithPopUp(_AbstractWidgetWithPopUpMixin):
    """
    A mixin  to allow for checkable buttons with a pop up that appears whenever
    the button is pressed. Note that when the pop up is visible, the button is
    set to be "checked", which is supposed to change the appearance of push
    buttons or tool buttons on certain platforms.

    Note: This mixin should be used with subclasses of
    `QtWidgets.QAbstractButton`.

    :ivar popUpClosing: A signal emitted when the pop up is closed.
    :vartype popUpClosing: `PyQt5.QtCore.pyqtSignal`

    The signal is emitted with:

        - REJECT if the user closed the pop up by pressing Esc
        - ACCEPT if the user closed the pop up by hitting Enter or by shifting
          focus
        - ACCEPT_MULTI if the user closed the pop up by hitting Ctrl+Enter
        - UNKNOWN if the event that closed the widget is not known (this happens
            when we use the Qt.PopUp window flag to do the popup closing for us)

    :ivar _pop_up: The pop up widget
    :vartype _pop_up: `PopUp`
    """

    popUpClosing = QtCore.pyqtSignal(int)

    def __init__(self, parent, pop_up_class=None):
        """
        :param parent: The Qt parent widget
        :type parent: `PyQt5.QtWidgets.QWidget`

        :param pop_up_class: The class of the pop up widget.  Should be a
            subclass of `PopUp`.
        :type pop_up_class: type
        """

        super().__init__(parent, pop_up_class)

        if not hasattr(self, 'clicked'):
            msg = ('This mixin should be used with classes that implement a'
                   ' "clicked" signal.')
            raise TypeError(msg)
        self.clicked.connect(self.onClicked)
        self.setCheckable(True)
        self.setChecked(False)

    def setPopUp(self, pop_up):
        # See super class for method documentation.
        super().setPopUp(pop_up)
        self._pop_up.visibilityChanged.connect(self._onPopUpVisibilityChanged)
        self._pop_up.setWindowFlags(Qt.FramelessWindowHint | Qt.Popup)

    def onClicked(self):
        """
        If the button is clicked, toggle the check state of the button, which
        should toggle the visibility of the pop up.
        """

        self.pop_up_visible = not self.pop_up_visible
        if self.pop_up_visible:
            # Qt toggles the UnderMouse attribute whenever there's an
            # enter or leave event. When the popup is visible, the button
            # won't receive any leave events leaving it in a "hovered"
            # state even after closing the popup. To prevent this,
            # we just manually set the attribute to False when we open
            # the popup.
            self.setAttribute(Qt.WA_UnderMouse, False)

    @property
    def pop_up_visible(self):
        """
        :return: whether the pop up frame is visible to its parent
        :rtype: bool
        """
        if self._pop_up is None:
            return False
        pop_up_parent = self._pop_up.parent()
        return self._pop_up.isVisibleTo(pop_up_parent)

    @pop_up_visible.setter
    def pop_up_visible(self, visible):
        """
        :param visible: whether the pop up should be visible
        :type visible: bool
        """

        self._pop_up.setVisible(visible)
        # Although the check state should be changed when the pop up
        # visibility changes, this is sometimes unreliable (such as when
        # pop up parent class is not visible, and therefore showEvent() is
        # never called and the visibilityChanged signal is not emitted)
        self._onPopUpVisibilityChanged(visible)

    def _onPopUpVisibilityChanged(self, visible):
        """
        When the pop up visibility changes, respond by changing the check state
        of the button and positioning the pop up appropriately. The button
        should be checked if and only if the pop up is visible, for appearance
        purposes, even if this is subclassed with a button type that is not
        generally considered "checkable", such as a `QPushButton` or a
        `QToolButton`.

        :param visible: whether the pop up is now visible
        :type visible: bool
        """

        if self.isChecked() != visible:
            self.setChecked(visible)

        if visible:
            self._setPopUpGeometry()
        if not visible:
            self.popUpClosing.emit(UNKNOWN)

    def _setPopUpGeometry(self):
        """
        Position the popup frame according to the specified alignment settings.
        """

        pop_up = self._pop_up
        if pop_up is None:
            return
        pop_up_height, pop_up_width = pop_up.height(), pop_up.width()
        btn_height, btn_width = self.height(), self.width()
        btn_pos = self.mapToGlobal(QtCore.QPoint(0, 0))
        btn_x, btn_y = btn_pos.x(), btn_pos.y()

        if self._popup_valign in [self.ALIGN_TOP, self.ALIGN_AUTO]:
            # Place pop up directly above button
            popup_new_y = btn_y - pop_up_height
        elif self._popup_valign == self.ALIGN_BOTTOM:
            # Place pop up directly below button
            popup_new_y = btn_y + btn_height

        if self._popup_halign == self.ALIGN_LEFT:
            # Align right edge of pop up with right edge of button
            popup_new_x = btn_x + btn_width - pop_up_width
        elif self._popup_halign == self.ALIGN_AUTO:
            # Align middle of pop up with middle of button
            popup_new_x = btn_x + 0.5 * (btn_width - pop_up_width)
        elif self._popup_halign == self.ALIGN_RIGHT:
            # Align left edge of pop up with left edge of button
            popup_new_x = btn_x

        pop_up.move(popup_new_x, popup_new_y)

    def setChecked(self, checked):
        """
        Set the button check state, and also alter the pop up visibility. Note
        that the button should be checked if and only if the pop up is visible,
        and that changing the visibility of the pop up (e.g. by clicking the
        button) will also change the check state.
        :param checked: whether the button should be checked
        :type checked: bool
        """

        super().setChecked(checked)
        if self.pop_up_visible != checked:
            self.pop_up_visible = checked


class PushButtonWithPopUp(_AbstractButtonWithPopUp, QtWidgets.QPushButton):
    """
    A checkable push button  with a pop up that appears whenever the button
    is pressed.
    """

    def __init__(self, parent, pop_up_class=None):
        super().__init__(parent, pop_up_class)


class PushButtonWithIndicatorAndPopUp(_AbstractButtonWithPopUp,
                                      hyperlink.ButtonWithArrowMixin,
                                      QtWidgets.QPushButton):
    """
    A push button with a menu indicator arrow which shows a pop up when the
    button is pressed.
    """
    pass


class LinkButtonWithPopUp(_AbstractButtonWithPopUp,
                          hyperlink.ButtonWithArrowMixin, hyperlink.MenuLink):
    """
    A push button that is rendered as a blue link, with a pop up that appears
    whenever the button is pressed.
    See schrodinger.ui.qt.standard_widgets.hyperlink.MenuLink
    """
    pass


class ToolButtonWithPopUp(_AbstractButtonWithPopUp, QtWidgets.QToolButton):
    """
    A checkable tool button  with a pop up that appears whenever the button
    is pressed.
    """

    def __init__(self,
                 parent,
                 pop_up_class=None,
                 arrow_type=Qt.UpArrow,
                 text=None):
        """
        :param parent: The Qt parent widget
        :type parent: `PyQt5.QtWidgets.QWidget`

        :param pop_up_class: The class of the pop up widget.  Should be a
            subclass of `PopUp`.
        :type pop_up_class: type

        :param arrow_type: Type of arrow to display in the button
        :type arrow_type: `Qt.ArrowType`

        :param text: Text to set for this button. If not specified, only an icon
            will be shown.
        :type text: str
        """
        super().__init__(parent, pop_up_class)
        self.setArrowType(arrow_type)
        if text is not None:
            self.setToolButtonStyle(Qt.ToolButtonTextBesideIcon)
            self.setText(text)


class AddButtonWithIndicatorAndPopUp(PushButtonWithIndicatorAndPopUp):
    """
    PushButton with a "+" icon and "Add" text. Button also has a menu
    indicator which shows a pop up when clicked.
    """

    def __init__(self, *args, **kwargs):
        super().__init__(*args, *kwargs)
        self.setIcon(QtGui.QIcon(icons.ADD_LB))
        self.setIconSize(QtCore.QSize(20, 20))
        self.setText("Add")
        if sys.platform.startswith("darwin"):
            self.setFixedWidth(90)
        else:
            self.setFixedWidth(75)
        self.setStyleSheet("QPushButton {text-align:left;}")