Source code for schrodinger.application.matsci.gutils

"""
Module for gui utility classes and functions

Copyright Schrodinger, LLC. All rights reserved.
"""

import os
import re
from contextlib import contextmanager

import schrodinger
from schrodinger.application.matsci import jobutils
from schrodinger.application.matsci import msprops
from schrodinger.Qt import QtCore
from schrodinger.Qt import QtGui
from schrodinger.Qt import QtWidgets
from schrodinger.Qt.QtCore import Qt
from schrodinger.ui import maestro_ui
from schrodinger.ui.qt import appframework
from schrodinger.ui.qt import filedialog
from schrodinger.ui.qt import swidgets
from schrodinger.ui.qt import utils as qtutils
from schrodinger.ui.qt.appframework2 import af2
from schrodinger.ui.qt.appframework2 import validation
from schrodinger.ui.qt import messagebox

NUM_RE = re.compile(r'(\d+)')
STATUS_MSG_LONG_DISPLAY = 20000

maestro = schrodinger.get_maestro()

DISALLOWED_ASL_FLAGS = frozenset(('entry.id', 'entry.name'))


[docs]def validate_child_widgets(child_widgets): """ Runs each widget's validators while respecting the validation_order across all other widgets. Rather than running validators one widget at a time, ensures that the validator with smallest validation_order across widgets is run first, and so on. Validation methods that call this function and return its value should be decorated with @validation.multi_validator() :param list child_widgets: The widgets to run validations for :rtype: validation.ValidationResults :return: The results of validations, or the results up to and including the first failed validation. """ validators = [] for widget in child_widgets: validators.extend(validation.find_validators(widget)) validators.sort(key=lambda method: method.validation_order) results = validation.ValidationResults() for validate_method in validators: result = validate_method() results.add(result) if not result: break return results
[docs]def format_tooltip(tip, min_width=None): """ Return a formatted tooltip. :type tip: str :param tip: the tip text :type min_width: int or None :param min_width: the minimum tip width, if None a default is used :rtype: str :return: the formatted tool tip """ tip = tip.strip() dummy = 'I think this seems like a good tool tip length.' min_width = min_width or len(dummy) if len(tip) <= min_width: return tip start, rest = tip[:min_width], tip[min_width:] if start[-1] == ' ': return f'<nobr>{start}</nobr>{rest}' if rest[0] == ' ': return f'<nobr>{start} </nobr>{rest[1:]}' words = rest.split() start += words[0] rest = ' '.join(words[1:]) if rest: return f'<nobr>{start} </nobr>{rest}' else: return start
[docs]def load_job_into_panel(entry_id, panel, load_func): """ Load the entry's job into the panel :param int entry_id: The entry to get source path from :param `af2.App` panel: The panel to load the job into :param callable load_func: The panel method to call to load the job :return bool: Whether loading was successful """ ptable = maestro.project_table_get() row = ptable.getRow(entry_id) job_dir = jobutils.get_source_path(row, existence_check=False) if job_dir: if os.path.exists(job_dir): load_func(job_dir) else: panel.error(f'Could not find job directory: {job_dir}') return False else: panel.error('Could not get the job directory path from entry.') return False return True
[docs]def load_entries_for_panel(*entry_ids, panel=None, load_func=None, included_entry=False, selected_entries=False, load_job=False): """ :param tuple entry_ids: Entry ids to load into the panel :param `af2.App` panel: The panel to populate :param callable load_func: The panel method to call to load the entries :param bool included_entry: Whether the group's entries should be included for the panel :param bool selected_entries: Whether the group's entries should be selected for the panel :param bool load_job: Whether the entry's job should be loaded into the panel :raise RuntimeError: If neither included nor selected entries is being used """ if not entry_ids: return if load_job: load_job_into_panel(entry_ids[0], panel, load_func) return if included_entry: command = 'entrywsincludeonly entry ' + str(entry_ids[0]) input_state = af2.input_selector.InputSelector.INCLUDED_ENTRY elif selected_entries: command = 'entryselectonly entry ' + ' '.join(map(str, entry_ids)) input_state = af2.input_selector.InputSelector.SELECTED_ENTRIES else: raise RuntimeError("One of load keywords should be True.") if hasattr(panel, 'input_selector') and panel.input_selector is not None: panel.input_selector.setInputState(input_state) elif hasattr(panel, '_if') and panel._if is not None: panel._if.setInputState(input_state) maestro.command(command) if load_func: load_func()
[docs]def sub_formula(formula): """ Add <sub></sub> tags around numbers. Can be used to prettify unit cell formula. :type formula: str :param formula: Formula to add tags around numbers :rtype: str :return: Formula with tags added """ return NUM_RE.sub(r'<sub>\1</sub>', formula)
[docs]def get_row_from_pt(maestro, entry_id): """ Get row from the PT from entry_id :param str entry_id: Entry ID :param maestro maestro: Maestro instance :rtype: ProjectRow or None :return: Row or None, if not found """ ptable = maestro.project_table_get() try: row = ptable.getRow(entry_id) except (ValueError, TypeError): return return row
[docs]class WheelEventFilterer(QtCore.QObject): """ An event filter that turns off wheel events for the affected widget """
[docs] def eventFilter(self, unused, event): """ Filter out mouse wheel events :type unused: unused :param unused: unused :type event: QEvent :param event: The event object for the current event :rtype: bool :return: True if the event should be ignored (a Mouse Wheel event) or False if it should be passed to the widget """ return isinstance(event, QtGui.QWheelEvent)
[docs]def turn_off_unwanted_wheel_events(widget, combobox=True, spinbox=True, others=None): """ Turns off the mouse wheel event for any of the specified widget types that are a child of widget Note: The mouse wheel will still scroll an open pop-up options list for a combobox if the list opens too large for the screen. Only mouse wheel events when the combobox is closed are ignored. :type widget: QtWidgets.QWidget :param widget: The widget to search for child widgets :type combobox: bool :param combobox: True if comboboxes should be affected :type spinbox: bool :param spinbox: True if spinboxes (int and double) should be affected :type others: list :param others: A list of other widget classes that should be affected """ affected = [] if others: affected.extend(others) if combobox: affected.append(QtWidgets.QComboBox) if spinbox: affected.append(QtWidgets.QAbstractSpinBox) for wclass in affected: for child in widget.findChildren(wclass): child.installEventFilter(WheelEventFilterer(widget))
[docs]def run_parent_method(obj, method, *args): """ Try to call a function of a parent widget. If not found, parent of the parent will be used and so on, until there are no more parents. `*args` will be passed to the found function (if any). :param QWidget obj: QWidget to use :param str method: Method name to be called """ parent = obj.parentWidget() while parent is not None: if hasattr(parent, method): getattr(parent, method)(*args) return parent = parent.parentWidget()
[docs]def fill_table_with_data_frame(table, data_frame): """ Fill the passed QTableWidget with data from the passed pandas data frame :param QTableWidget table: The table to fill :param `pandas.DataFrame` data_frame: The data frame to read values from """ num_rows = data_frame.shape[0] columns = data_frame.columns table.setColumnCount(len(columns)) table.setHorizontalHeaderLabels(columns) table.setRowCount(0) with disable_table_sort(table): for row in range(num_rows): table.insertRow(row) for col in range(len(columns)): item = swidgets.STableWidgetItem(editable=False, text=str(data_frame.iat[row, col])) table.setItem(row, col, item)
[docs]@contextmanager def disable_table_sort(table): """ CM to disable sorting on a table. :type table: QTableWidget :param table: the table """ # see MATSCI-9505 - sorting should be disabled while rows are changing table.setSortingEnabled(False) try: yield finally: table.setSortingEnabled(True)
[docs]@contextmanager def shrink_panel(panel): """ Shrinks the panel when removing widgets so that blank space is removed :param `af2.BasePanel` panel: The panel to shrink """ layout = panel.panel_layout # Save the original size constraint so we can restore it at the end orig_constraint = layout.sizeConstraint() if orig_constraint == layout.SetDefaultConstraint: # SetDefaultConstraint works weirdly. It only sets the minimum size, # but not the maximum size. So resetting the sizeConstraint to # SetDefaultConstraint after setting it to SetFixedSize does not allow # the panel to be resized larger. SetMinAndMaxSize is really how panels # behave by default. orig_constraint = layout.SetMinAndMaxSize # Setting to SetFixedSize forces the panel to resize to its sizeHint layout.setSizeConstraint(layout.SetFixedSize) try: yield finally: panel.adjustSize() # Reset the size constraint so that the panel can be manually resized layout.setSizeConstraint(orig_constraint)
[docs]def remove_spacers(layout, orientation=None): """ Remove all spacer items from the given layout and any layout it contains :param `QtWidgets.QLayout` layout: :type orientation: str or Qt.Orientation :param orientation: The orientation of the spacer to remove. Should be either `swidgets.VERTICAL` or `swidgets.HORIZONTAL`, or a Qt.Orientation enum """ if orientation == swidgets.VERTICAL: orientation = Qt.Vertical elif orientation == swidgets.HORIZONTAL: orientation = Qt.Horizontal indexes_to_remove = [] for index in range(layout.count()): item = layout.itemAt(index) sp_item = item.spacerItem() if sp_item: if not orientation or sp_item.expandingDirections() == orientation: indexes_to_remove.append(index) elif item.layout(): # We need to iterate down through nested layouts - note that # widget.findChildren(QtWidgets.QSpacerItem) does NOT work. remove_spacers(item.layout(), orientation=orientation) # Reverse the list so the indexes don't change as we remove items indexes_to_remove.reverse() for index in indexes_to_remove: layout.takeAt(index)
[docs]def expected_text_width(widget, text=None, chars=None): """ Get the expected width of some text given the widget's font settings :param QWidget widget: The widget the text will display on :param str text: The exact text to display :param int chars: The number of characters that will be displayed. An average width for this many characters will be return. :rtype: int :return: The expected width of the given text when displayed on widget """ assert text or chars metrics = widget.fontMetrics() if chars: return metrics.averageCharWidth() * chars else: return metrics.boundingRect(text).width()
[docs]def add_maestro_banner(text, text2='', action='', command='', action2='', command2='', action_in_new_line=False): """ Add a temporary Maestro banner at the top of the Workspace. Supports adding clickable hyperlinks with actions :param str text: The banner text See Prototype::addPythonBanner() in maestro-src/src/main/prototype.h for other arguments """ maestro_hub = maestro_ui.MaestroHub.instance() maestro_hub.addBanner.emit(text, text2, action, command, action2, command2, action_in_new_line)
[docs]def get_matplotlib_toolbar_message(event): """ Get the toolbar message based on the mouse event :param `matplotlib.backend_bases.MouseEvent` event: Contains information about the location of the cursor and above which `matplotlib.axes.Axes` object the cursor was at the time of the event, if any. :return str: The toolbar message """ # Add mode message mode = event.canvas.toolbar.mode if mode: message = mode else: message = '' # Add coordinates to message xval, yval = (event.xdata, event.ydata) # xval and yval will be None if outside the plot if xval is not None: # Create axis message with coordinates concatenator = ', ' if mode else '' message += f'{concatenator}x={xval:.4f}\ty={yval:.4f}' return message
[docs]def subscript_chemical_formula(formula): """ Subscript all numbers in a string. Used to make chemical formulas look prettier. :param str formula: The chemical formula you want to format, e.g., 'H2O' :return str: The subscripted chemical formula, e.g., 'H<sub>2</sub>O' """ new_formula = '' # Divide the formula into groups of numbers and letters for subformula in re.split(r'(\d+)', formula): # Subscript the numbers, but not the letters if subformula.isdigit(): subformula = ''.join( swidgets.SUB_DIGITS[int(digit)] for digit in subformula) new_formula += subformula return new_formula
[docs]def subscript_cg_formula(formula): """ Subscript numbers in a string that follow closing parentheses. Used to make chemical formulas for coarse-grain structures look prettier. :param str formula: The chemical formula you want to format, e.g., '(W)80(BENZ)20)' :return str: The subscripted chemical formula, e.g., '(W)<sub>80</sub>(BENZ)<sub>20</sub>)' """ new_formula = '' # Split the formula into expressions inside and outside each set of # paretheses, () for subformula in re.split(r'\(|\)', formula): # Subscript the numbers if subformula.isdigit(): subformula = ''.join( swidgets.SUB_DIGITS[int(digit)] for digit in subformula) swidgets.SUB_TEXT # Put the parentheses back around the grain names elif subformula: subformula = f'({subformula})' new_formula += subformula return new_formula
[docs]def error(widget, msg): """ Display an error dialog with a message :param QWidget widget: Widget :type msg: str :param msg: The message to display in the error dialog """ with qtutils.remove_wait_cursor: messagebox.show_error(widget, msg)
[docs]def info(widget, msg): """ Display an information dialog with a message :param QWidget widget: Widget :param str msg: The message to display in the information dialog """ with qtutils.remove_wait_cursor: messagebox.show_info(widget, msg)