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)
"""
[docs]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)
[docs] 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)
[docs] def closeEvent(self, event):
self.visibilityChanged.emit(False)
return super().closeEvent(event)
[docs] def setup(self):
"""
Subclass-specific initialization. Subclasses must implement this
function.
"""
raise NotImplementedError
[docs] 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()
[docs] 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)
[docs] 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
[docs] 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()
[docs] 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()
[docs] 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
[docs] def showEvent(self, event):
"""
Emit a signal every time this pop up is shown.
"""
super().showEvent(event)
self.visibilityChanged.emit(True)
[docs] 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)
[docs]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)
[docs] 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()
[docs] def focusInEvent(self, event):
"""
When the line edit receives focus, show the pop up
"""
self._pop_up.show()
super(LineEditWithPopUp, self).focusInEvent(event)
[docs] 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)
[docs] 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)
[docs]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)
[docs]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)
[docs] 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
[docs] 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)
[docs] def setEditorData(self, editor, index):
# See Qt documentation
value = index.data()
editor.setText(value)
[docs] def setModelData(self, editor, model, index):
# See Qt documentation
if editor.hasAcceptableInput():
value = editor.text()
model.setData(index, value)
[docs] 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)
[docs] 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
[docs]class PushButtonWithPopUp(_AbstractButtonWithPopUp, QtWidgets.QPushButton):
"""
A checkable push button with a pop up that appears whenever the button
is pressed.
"""
[docs]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
[docs]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
[docs]class ToolButtonWithPopUp(_AbstractButtonWithPopUp, QtWidgets.QToolButton):
"""
A checkable tool button with a pop up that appears whenever the button
is pressed.
"""
[docs] 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)
[docs]class AddButtonWithIndicatorAndPopUp(PushButtonWithIndicatorAndPopUp):
"""
PushButton with a "+" icon and "Add" text. Button also has a menu
indicator which shows a pop up when clicked.
"""
[docs] 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;}")