Source code for schrodinger.ui.qt.widgetmixins.basicmixins

import collections
import enum
import inspect
import sys
from unittest import mock

from schrodinger.Qt import QtCore
from schrodinger.Qt import QtWidgets
from schrodinger.Qt.QtCore import Qt
from schrodinger.ui.qt import messagebox
from schrodinger.ui.qt import swidgets
from schrodinger.ui.qt import utils as qt_utils
from schrodinger.ui.qt.appframework2 import appmethods
from schrodinger.ui.qt.appframework2 import debug
from schrodinger.ui.qt.appframework2 import validation
from schrodinger.ui.qt.standard_widgets import statusbar

LINE_STYLE = '''color: #666666; margin-left: 5px; margin-right: 5px;'''


class InitMixin:
    """
    A mixin that breaks up widget initialization into several discrete steps
    and provides some basic formatting boilerplate. Each of these steps is
    meant to be overridden during subclassing so that

    1. The initialization is organized
    2. Repetition when subclassing is minimized, because the five provided
       initialization methods can be overridden rather than all of `__init__`.

    Expects to be inherited by a `QtWidgets.QWidget`.

    To install a Qt Designer ui module on the widget, set the class variable
    ui_module to the *_ui module created by pyuic.
    """
    ui_module = None  # set to the ui module (ex. ui_module = foo_gui_ui)

    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self.ui = None
        self.initSetOptions()
        self.initSetUp()
        self.initLayOut()
        self.initSetDefaults()
        self.initFinalize()

    def initSetOptions(self):
        """
        Suggested subclass use: set instance variables, excluding layouts and
        subwidgets. Also use here to (optionally) apply the legacy stylesheet
        spacing settings (PANEL-19101).
        """

    def initSetUp(self):
        """
        Creates widget from `ui` and stores it `ui_widget`.

        Suggested subclass use: create and initialize subwidgets,
        and connect signals.
        """

        setup_panel_ui(self)

    def initLayOut(self):
        """
        Create a vertical layout for the widget (`widget_layout`) and populate
        it with two vertical sub-layouts: `main_layout` and `bottom_layout`.

        If the user has specified the `ui` data member, insert the resultant
        `ui_widget` into `main_layout`.

        If the widget already has a layout defined, this method will produce a
        warning (but not a traceback). `main_layout` and `bottom_layout` will
        be inserted into the existing widget layout, which will not be the same
        as `widget_layout`. It is therefore recommended that this mixin is used
        only with widgets that do not already have a layout.

        Suggested subclass use: create, initialize, and populate layouts.
        """

        self.widget_layout = swidgets.SVBoxLayout()
        self.setWidgetLayout()
        self.widget_layout.setContentsMargins(2, 2, 2, 2)
        self.main_layout = swidgets.SVBoxLayout()
        self.bottom_layout = swidgets.SHBoxLayout()

        self.widget_layout.addLayout(self.main_layout)
        self.widget_layout.addLayout(self.bottom_layout)
        if self.ui:
            self.main_layout.addWidget(self.ui_widget)

    def setWidgetLayout(self):
        """
        Set the widget layout
        """
        if self.layout():
            self.layout().addLayout(self.widget_layout)
        else:
            self.setLayout(self.widget_layout)

    def initSetDefaults(self):
        """
        Set widget to its default state. If the widget uses a model/mapper, it's
        preferable to reset the widget state by resetting the model.
        """

    def initFinalize(self):
        """
        Suggested subclass use: perform any remaining initialization.
        """


class ExecutionMixin:
    """
    A mixin that facilitates the use of the `run` method, which provides a
    flexible means of opening the inheriting widget.

    Expects to be inherited by a `QtWidgets.QWidget`.

    self._return_value is provided as a way to give self.run(blocking=True) a
    return value.

    :cvar SHOW_AS_WINDOW: Whether the widget is intended to be shown as a window
        (e.g. the widget is dialog-like). If you create a child widget using
        this class and unexpectedly see its contents inside the parent, set this
        to True.
    :vartype SHOW_AS_WINDOW: bool
    """
    SHOW_AS_WINDOW = False

    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self._finished_callback = None
        self._execution_event_loop = None
        self._return_value = None
        self.setWindowFlags()

    def run(self, blocking=False, modal=False, finished_callback=None):
        """
        Show this widget, while optionally blocking, making the widget window
        modal, and registering a callback for when the widget is closed again.

        :param blocking: if True, block progress in the calling method until
                the widget is closed again.
        :type blocking: bool

        :param modal: if True, open this widget as window modal. Otherwise,
                open this widget as nonmodal.
        :type modal: bool

        :param finished_callback: an object that will be called with no
                arguments when this widget is closed.
        :type finished_callback: a callable object
        """

        self.setWindowFlagsForRun()

        self._finished_callback = finished_callback
        if modal:
            if self.parent():
                self.setWindowModality(Qt.WindowModal)
            else:
                self.setWindowModality(Qt.ApplicationModal)
        else:
            self.setWindowModality(Qt.NonModal)

        self.show()
        self.raise_()
        self.activateWindow()
        self.setAttribute(QtCore.Qt.WA_Resized)

        if blocking:
            self._execution_event_loop = QtCore.QEventLoop()
            self._execution_event_loop.exec_()
            retval = self._return_value
            self._return_value = None
            return retval

    def setWindowFlagsForRun(self):
        self.setWindowFlags(self.windowFlags() | QtCore.Qt.Window)

    def setWindowFlags(self, flags=None):
        if flags is None:
            if self.SHOW_AS_WINDOW:
                flags = self.windowFlags() | QtCore.Qt.Window
            else:
                return
        super().setWindowFlags(flags)

    def closeEvent(self, event):
        """
        When this widget is closed, end the blocking effect (if in place) and
        call the callback (if supplied), removing both afterward.
        """

        super().closeEvent(event)

        if self._finished_callback:
            self._finished_callback()
            self._finished_callback = None

        if self._execution_event_loop:
            self._execution_event_loop.exit()
            self._execution_event_loop = None

    if sys.platform.startswith("linux"):

        def setVisible(self, set_visible):
            qt_utils.linux_setVisible_positioning_workaround(
                self, super(), set_visible)

    def debug(self):
        debug.start_gui_debug(self, globals(), locals())


class MessageBoxMixin:
    """
    A mixin that facilitates the creation of several common types of message
    boxes as children of the inheriting class.

    """

    def _getMessageBoxParent(self):
        p = self
        while True:
            if isinstance(p, QtWidgets.QWidget):
                return p
            if not isinstance(p, QtCore.QObject):
                return None
            p = p.parent()

    def showMessageBox(self, *args, **kwargs):
        """
        Shows a popup message box. For parameter documentation see
        `messagebox.MessageBox`.
        """
        messagebox.show_message_box(self._getMessageBoxParent(), *args,
                                    **kwargs)

    def warning(self, *args, **kwargs):
        """
        Shows a popup warning message box. For parameter documentation see
        `messagebox.MessageBox`.
        """
        messagebox.show_warning(self._getMessageBoxParent(), *args, **kwargs)

    def error(self, *args, **kwargs):
        """
        Shows a popup error message box. For parameter documentation see
        `messagebox.MessageBox`.
        """
        messagebox.show_error(self._getMessageBoxParent(), *args, **kwargs)

    def info(self, *args, **kwargs):
        """
        Shows a popup information message box. For parameter documentation see
        `messagebox.MessageBox`.
        """
        messagebox.show_info(self._getMessageBoxParent(), *args, **kwargs)

    def question(self, *args, **kwargs):
        """
        Shows a popup question message box. For parameter documentation see
        `messagebox.QuestionMessageBox`.
        """
        return messagebox.show_question(self._getMessageBoxParent(), *args,
                                        **kwargs)

    def forgetMessageBoxResponse(self, key):
        """
        Forgets any previously saved response that was stored via a
        save_response_key.

        :param key: the key for the response to forget
        """
        msg_box = messagebox.MessageBox(
            parent=self._getMessageBoxParent(), save_response_key=key)
        msg_box.forgetResponse()


class BottomButtonMixin:
    """
    Provides an interface for quickly adding buttons to the bottom of the
    widget. This includes standard buttons like OK, Cancel and Help, as well as
    custom buttons, specified with the appmethods.custom decorator.

    Requires InitMixin. Works with ExecutionMixin, if present, such that value
    of self.accepted will be returned from self.run(blocking=True)

    To add standard buttons other than "Help", set self.std_btn_specs with keys
    from the BottomButtonMixin.StdBtn enum and values which are a tuple of
    (slot, text, tooltip).

        slot: a function that will be called when the button is clicked. The
        slot provides custom functionality on top of the standard behavior of
        the button. Returning False will abort the default behavior. Use None
        for the slot if there is no custom behavior to implement.

        text: customized button text to override the standard text ("OK",
        "Cancel", etc.)

        tooltip: tooltip text

    All parts of the spec are optional, and the tuple can be any length up to 3.
    As a convenience, in lieu of a tuple of length 1, the slot itself can be
    directly supplied as the spec. Examples::

        self.std_btn_specs = {self.StdBtn.Ok : (self.onOkClicked, 'Run'),
                              self.StdBtn.Cancel : self.onCancelClicked,
                              self.StdBtn.Reset : None}

    To get the help button to appear, just set self.help_topic to the
    appropriate string. No button spec is required for the help button.

    To add custom buttons, decorate the intended slot with::

        @appmethods.custom('Custom button text', order=1, tooltip=None)
        def mySlot(self):
            ...

    The order and tooltip arguments are optional. Custom buttons will be
    arranged from left to right according to the `order` value.

    """

    class StdBtn(enum.IntEnum):
        Ok = 0
        Cancel = 1
        Reset = 2
        Help = 3

    class BtnPos(enum.IntEnum):
        Left = 0
        Middle = 1
        Right = 2

    # Base configuration for each standard button. The value is a tuple of
    # button text and button position (which side of the widget it goes in)
    _STD_BTN_CFG = collections.OrderedDict(
        [(StdBtn.Ok, ('OK', BtnPos.Right)), (StdBtn.Cancel, ('Cancel',
                                                             BtnPos.Right)),
         (StdBtn.Help, ('Help', BtnPos.Right)), (StdBtn.Reset, ('Reset',
                                                                BtnPos.Left))])

    def __init__(self, *args, **kwargs):
        self._accepted = None
        self.help_topic = None
        self.std_btn_specs = {}
        self._public_btn_slots = {}
        super().__init__(*args, **kwargs)

    def initSetUp(self):
        super().initSetUp()
        self._bbm_createBottomButtons()

    def initLayOut(self):
        super().initLayOut()
        self.bottom_layout.addLayout(self.bottom_left_layout)
        self.bottom_layout.addStretch()
        self.bottom_layout.addLayout(self.bottom_middle_layout)
        self.bottom_layout.addStretch()
        self.bottom_layout.addLayout(self.bottom_right_layout)
        # Set the stretch factor of the middle layout to 1 so that any
        # expanding widgets in the middle layout can take up the maximum
        # amount of space.
        self.bottom_layout.setStretch(2, 1)

    @property
    def accepted(self):
        return self._accepted

    @accepted.setter
    def accepted(self, value):
        self._accepted = value
        # Set this value to be used by ExecutionMixin
        self._return_value = value

    def show(self):
        """
        Override the show method to clear and previous value of self.accepted
        """
        self.accepted = None
        super().show()

    def closeEvent(self, event):
        """
        Ensures proper handling of OK, Cancel, and [X] button clicks
        """
        if self.accepted:  # OK button clicked
            super().closeEvent(event)
            return

        # There is no Cancel button defined - use standard close
        if self.StdBtn.Cancel not in self._public_btn_slots:
            super().closeEvent(event)
            return

        # Either Cancel or [X] button was clicked
        if self._bbm_runPublicSlot(self.StdBtn.Cancel) is False:
            event.ignore()  # Abort the closeEvent
        else:
            self.accepted = False
            super().closeEvent(event)

    def _bbm_createBottomButtons(self):
        """
        Creates the bottom buttons, connects them to the appropriate slots.
        """
        self._bbm_makeLayouts()
        if self.help_topic:
            # Check on the off-chance user wants custom help behavior
            if self.StdBtn.Help not in self.std_btn_specs:
                self.std_btn_specs[self.StdBtn.Help] = None

        for btn_type in self._STD_BTN_CFG:
            if btn_type in self.std_btn_specs:
                spec = self.std_btn_specs[btn_type]
                self._bbm_addStandardButton(btn_type, spec)

        methods = appmethods.MethodsDict(self)
        buttons = []
        for method in methods.custom_methods:
            button = self._bbm_makeButton(method, method.button_text,
                                          method.tooltip)
            setattr(self, method.__name__ + '_btn', button)
            buttons.append(button)
            self.bottom_left_layout.addWidget(button)

    def _bbm_makeLayouts(self):
        self.bottom_left_layout = QtWidgets.QHBoxLayout()
        self.bottom_middle_layout = QtWidgets.QHBoxLayout()
        self.bottom_right_layout = QtWidgets.QHBoxLayout()

    def _bbm_makeButton(self, slot, text, tooltip=None):
        """
        Makes a single button, connects the slot and adds a tooltip if
        specified. One important feature is that these buttons are instances of
        AcceptsFocusPushButton. This type of buttons takes the focus when it is
        clicked, which ensures that delegates lose focus and hence commit any
        edits made in the delegate before the button's slot is executed.

        :param slot: the slot
        :type slot: callable
        :param text: the button text
        :type text: str
        :type tooltip: str
        """
        button = qt_utils.AcceptsFocusPushButton(text)
        button.clicked.connect(slot)
        if tooltip is not None:
            button.setToolTip(tooltip)
        return button

    def _bbm_addStandardButton(self, button_type, button_spec):
        """
        Adds a standard button of a specified type and options.

        :param button_type: what kind of standard button to add
        :type button_type: BottomButtonMixin.StdBtn
        :param button_spec: see doc for self.std_btn_specs
        :param button_cfg: see doc for _STD_BTN_CFG
        """
        public_slot, btn_text, tooltip = self._bbm_processButtonSpec(
            button_spec)
        button_cfg = self._STD_BTN_CFG[button_type]
        processed_cfg = self._bbm_processButtonConfig(button_cfg)
        default_text, btn_pos, private_slot, btn_attr_name = processed_cfg

        text = btn_text or default_text
        if button_type in (self.StdBtn.Help, self.StdBtn.Reset):
            btn = swidgets.HelpButton() if button_type is self.StdBtn.Help \
                else swidgets.ResetButton()
            btn.clicked.connect(private_slot)
            if tooltip is not None:
                btn.setToolTip(tooltip)
        else:
            btn = self._bbm_makeButton(private_slot, text, tooltip)

        if btn_pos == self.BtnPos.Left:
            self.bottom_left_layout.addWidget(btn)
        if btn_pos == self.BtnPos.Middle:
            self.bottom_middle_layout.addWidget(btn)
        if btn_pos == self.BtnPos.Right:
            self.bottom_right_layout.addWidget(btn)
        setattr(self, btn_attr_name, btn)
        self._public_btn_slots[button_type] = public_slot

    def _bbm_processButtonSpec(self, button_spec):
        """
        Processes the button spec to determine the public slot, text, and
        tooltip. The public slot is called by the private slot. See
        self._bbm_runPublicSlot for more info.
        """
        btn_text = None
        tooltip = None
        if (not isinstance(button_spec, collections.Iterable)  # slot only
                or isinstance(button_spec, mock.Mock)):  # for unit testing
            slot = button_spec
        elif len(button_spec) == 1:
            slot = button_spec[0]
        elif len(button_spec) == 2:
            slot, btn_text = button_spec
        elif len(button_spec) == 3:
            slot, btn_text, tooltip = button_spec
        else:
            raise ValueError(
                'Could not interpret button spec: %s' % button_spec)
        return slot, btn_text, tooltip

    def _bbm_processButtonConfig(self, button_cfg):
        """
        Process the button config to determine:
            default_text: the standard button text for this button
            btn_pos: does button go in the bottom left, middle or right
            private_slot: this is the true slot for the button

        :param button_cfg: see doc for _STD_BTN_CFG
        """
        default_text, btn_pos = button_cfg
        btn_name = default_text.lower()
        btn_attr_name = btn_name + '_btn'
        private_slot_name = '_bbm_on' + btn_name.capitalize() + 'BtnClicked'
        private_slot = getattr(self, private_slot_name)
        return default_text, btn_pos, private_slot, btn_attr_name

    #=========================================================================
    # Private slots
    #=========================================================================

    def _bbm_onHelpBtnClicked(self):
        if self._bbm_runPublicSlot(self.StdBtn.Help) is not False:
            qt_utils.help_dialog(self.help_topic, parent=self)

    def _bbm_onOkBtnClicked(self):
        if self._bbm_runPublicSlot(self.StdBtn.Ok) is not False:
            self.accepted = True
            self.close()

    def _bbm_onCancelBtnClicked(self):
        self.close()

    def _bbm_onResetBtnClicked(self):
        if self._bbm_runPublicSlot(self.StdBtn.Reset) is not False:
            self.initSetDefaults()

    def _bbm_runPublicSlot(self, btn_type):
        """
        Runs the public slot for the specified standard button type. The private
        slot is the actual slot for the button, and runs the built-in standard
        behavior for that button (e.g. the built-in behavior of the cancel
        button is to dismiss the widget). The public slot is called from the
        private slot for any custom behavior, and allows for the built-in action
        to be aborted by returning False.

        """
        public_slot = self._public_btn_slots[btn_type]
        if public_slot is None:
            return True
        return public_slot()


class BaseMixinCollection(BottomButtonMixin, InitMixin, ExecutionMixin,
                          MessageBoxMixin, validation.ValidationMixin):
    """
    A mixin that combines other widget mixins for convenience. See individual
    mixin classes for full documentation: `InitMixin`, `ExecutionMixin`,
    `MessageBoxMixin`, `validation.ValidationMixin`
    """


class DockableMixinCollection(BaseMixinCollection):
    """
    A mixin collection for dockable widgets.
    """
    SHOW_AS_WINDOW = False

    def setWidgetLayout(self):
        """
        Set the widget layout. A QDockWidget's layout does not allow nested
        layouts so we create a container widget to hold the widget layout.
        """
        top_level_widget = QtWidgets.QWidget()
        top_level_widget.setContentsMargins(0, 0, 0, 0)
        top_level_widget.setLayout(self.widget_layout)
        self.setWidget(top_level_widget)

    def setWindowFlagsForRun(self):
        """
        Don't set any window flags for run
        """


class StatusBarMixin:
    """
    A mixin that adds a status bar and repositions the help button to be on the
    status bar.
    Requires InitMixin and BottomButtonMixin.
    """

    # Overriding standard button configuration from BottomButtonMixin.
    _STD_BTN_CFG = collections.OrderedDict(
        [(BottomButtonMixin.StdBtn.Ok, ('OK', BottomButtonMixin.BtnPos.Right)),
         (BottomButtonMixin.StdBtn.Cancel, ('Cancel',
                                            BottomButtonMixin.BtnPos.Right)),
         (BottomButtonMixin.StdBtn.Help,
          ('Help', None)), (BottomButtonMixin.StdBtn.Reset, ('Reset', None))])

    def initSetUp(self):
        super().initSetUp()
        self.status_bar = statusbar.StatusBar(self)
        self.status_lbl = QtWidgets.QLabel()
        self.status_lbl.setObjectName("status_lbl")
        # A horizontal line above the status bar to separate it from main
        # panel widgets.
        self.separator = swidgets.SHLine()
        self.separator.setFrameShadow(QtWidgets.QFrame.Plain)
        self.separator.setStyleSheet(LINE_STYLE)

        if self.help_topic:
            self.status_bar.addPermanentWidget(self.help_btn)
        if self.StdBtn.Reset in self.std_btn_specs:
            self.status_bar.addWidget(self.reset_btn)

        self.status_bar.addWidget(self.status_lbl)

    def initLayOut(self):
        super().initLayOut()
        self.widget_layout.addWidget(self.separator)
        self.widget_layout.addWidget(self.status_bar)


def setup_panel_ui(panel):
    """
    Installs a ui module onto the specified panel.

    - If the panel subclasses a Ui_Form, the class itself will setup the
      ui_widget.
    - If the panel has a "ui" value set that is a Ui_Form instance, that will
      be used to populate the ui_widget.
    - If the panel has a "ui" value set that is a ui module containing
      Ui_Form, the Ui_Form will be instantiated and used to populate the
      ui_widget.

    :param panel: the panel to setup ui on
    :type panel: PanelMixin

    :raises TypeError: if the panel has a "ui" value set that is neither a
        Ui_Form instance nor a ui module
    """
    if panel.ui_module:
        if not inspect.ismodule(panel.ui_module):
            raise TypeError('ui_module must be a module')
        for name in dir(panel.ui_module):
            if name.startswith('Ui_'):
                attr = getattr(panel.ui_module, name)
                if inspect.isclass(attr):
                    panel.ui = attr()
                    break
    elif hasattr(panel, 'ui') and panel.ui is not None:
        pass  #TODO: deprecate old API
        #warnings.warn(
        #    'Directly setting self.ui to %s is deprecated. Set the '
        #    'ui_module variable instead.' % panel.ui,
        #    DeprecationWarning,
        #    stacklevel=5)
    if hasattr(panel, 'ui'):
        if hasattr(panel.ui, 'Ui_Form'):
            panel.ui = panel.ui.Ui_Form()

        if hasattr(panel.ui, 'setupUi'):
            panel.ui_widget = QtWidgets.QWidget(panel)
            panel.ui.setupUi(panel.ui_widget)
        elif panel.ui is not None:
            msg = ("self.ui must be a Ui_Form instance or ui module, got "
                   f"{panel.ui} instead")
            raise TypeError(msg)

    elif hasattr(panel, 'setupUi'):
        panel.ui_widget = QtWidgets.QWidget(panel)
        panel.setupUi(panel.ui_widget)


def is_dockable(window):
    return isinstance(window, QtWidgets.QDockWidget)


def is_docked(window):
    return is_dockable(window) and not window.isFloating()