"""
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 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 add_desmond_ms_logo(layout, **kwargs):
"""
Add a Desmond D. E. Shaw logo on the left and a MatSci logo on the right
:type layout: QBoxLayout
:param layout: The layout to add the logos to
"""
dlayout = appframework.make_desres_layout('Materials Science Suite',
**kwargs)
layout.addLayout(dlayout)
[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 locate_file_from_link(row,
linkprop=msprops.JAGUAR_OUTPATH_PROP,
fix_link=True,
parent=None,
ext='.out',
idtag='locate_file',
caption='Locate File',
source_subdir=None):
"""
Locate the the requested file from the given project row property. If it
isn't at the linked location, check the source directory for this row. If it
still can't be found, a file dialog will be posted for the user to locate
it.
:type row: `schrodinger.structure.Structure` or
`schrodinger.project.ProjectRow`
:param row: The project row or structure with the info
:param str linkprop: The property that gives the file link
:param bool fix_link: If True, the linkprop will be fixed with any new file
location found
:param `QWidget` parent: Any file dialog will be centered over this widget.
If not given, no file dialog will be posted.
:param str ext: The extension (including '.') of the file. Used only by the
file dialog.
:param str idtag: The id for the file dialog
:param str caption: The caption for the file dialog
:type source_subdir: str or True
:param str source_subdir: If given and the source directory property is
used to locate the file, look in this subdirectory of the source
directory. If source_subdir is simply True, then if file doesn't exists
in the current directory, the subdir name is taken from the final
directory in the path obtained from linkprop. For instance, if
source_subdir=True and the path obtained from linkprop is /a/b/c/d,
d will be the file name and c will be the source_subdir. If the source
path is used and found to be /e/f/g, the file looked for will be
/e/f/g/c/d.
:rtype: str or None
:return: The path to the file if found, otherwise None
"""
# Get path directly from link property
path = row.property.get(linkprop, "")
if os.path.exists(path):
return path
# Build path from row source path
if path:
# Grab the file name from the linkprop path
link_directory, file_name = os.path.split(path)
# Build the source path
source_path = jobutils.get_source_path(row)
if source_subdir and source_subdir is not True:
# source_subdir is a path
source_path = os.path.join(source_path, source_subdir)
path = os.path.join(source_path, file_name)
if not os.path.exists(path):
path = None
# Try the last subdir in the linkprop path
if source_subdir is True:
source_subdir = os.path.basename(link_directory)
path = os.path.join(source_path, source_subdir, file_name)
if not os.path.exists(path):
path = None
# Ask the user to locate the file
if parent and not path:
path = filedialog.get_open_file_name(parent=parent,
caption=caption,
filter=f'File (*{ext})',
id=idtag)
# Update the link property so we can find the file next time
if path and fix_link and linkprop:
row.property[linkprop] = path
return path
[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 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)