"""
Schrodinger version of the QFileDialog class of the QtGui module.
Defines a FileDialog class that mimics the Maestro's file dialogs.
Copyright Schrodinger, LLC. All rights reserved.
"""
# Contributors: Pat Lorton, Matvey Adzhigirey
import os
import os.path
import sys
from collections import OrderedDict
from past.utils import old_div
import schrodinger.ui.qt.icons as icons # noqa: F401
from schrodinger.Qt import QtCore
from schrodinger.Qt import QtGui
from schrodinger.Qt import QtWidgets
from schrodinger.ui import maestro_ui
from schrodinger.ui.maestro_ui import MM_QProjS
from schrodinger.ui.qt import utils as qt_utils
from schrodinger.utils import fileutils
[docs]def use_native_file_dialog():
return maestro_ui.maestro_native_file_dialog_is_enabled()
# Common filters:
MAESTRO_FILTER = filter_string_from_formats([fileutils.MAESTRO])
SD_FILTER = filter_string_from_formats([fileutils.SD])
PDB_FILTER = filter_string_from_formats([fileutils.PDB])
MAESTRO_SD_FILTER = filter_string_from_formats(
[fileutils.MAESTRO, fileutils.SD])
MAESTRO_PDB_FILTER = filter_string_from_formats(
[fileutils.MAESTRO, fileutils.PDB])
[docs]def filter_string_from_extensions(extensions, add_all=False):
"""
Create a filter string for the given extensions
:param dict extensions: Keys are descriptive file types ('Image file'),
values are an iterable of associated extension(s). If there is only one
extension it can be given as a simple string rather than an iterable of
length 1 (['.png', '.jpg'] or just '-2d.png').
:param bool add_all: Whether to add an additional "All files" filter
:rtype: str
:return: A string formatted for the file dialog filter keyword
"""
strings = []
for name, exts in extensions.items():
if isinstance(exts, str):
exts = [exts]
exstr = ' '.join('*' + x for x in exts)
strings.append(f'{name} ({exstr})')
if add_all:
strings.append('All files (*)')
return ';;'.join(strings)
def _get_standard_loc(standard_loc):
"""
Get the specified location in a way that works on Qt4 and Qt5. Once we've
fully switched to Qt5, this code can be inlined and this function can be
removed.
"""
standard_loc = getattr(QtCore.QStandardPaths, standard_loc)
return QtCore.QStandardPaths.writableLocation(standard_loc)
[docs]def get_existing_directory(parent="",
caption='Choose Directory',
dir=None,
accept_label='Choose',
file_mode=QtWidgets.QFileDialog.Directory,
*,
show_dirs_only=True,
**kwargs):
"""
Convenience function that returns a directory name as selected by the
user
The base class `base_file_dialog` documents the keyword arguments for this
class.
:type file_mode: QFileDialog.FileMode
:param file_mode: What the user can select. See the PyQt documentation for
QFileDialog.FileMode (currently AnyFile, ExistingFile, Directory and
ExistingFiles are the options).
:type show_dirs_only: bool
:param show_dirs_only: Whether we should show only directories in the file
dialog. If False, both files and directories will be shown.
:rtype: str or None
:return: full pathname of the file selected by the user, or None if Cancel
was pressed
"""
files = base_file_dialog(parent=parent,
caption=caption,
dir=dir,
accept_label=accept_label,
file_mode=file_mode,
show_dirs_only=show_dirs_only,
**kwargs)
try:
return files[0]
except (IndexError, TypeError):
return files
def _add_extension_if_necessary(files, filter):
"""
Attach the first extension in filter to each filename in files that does not
already have an extension
:type files: list
:param files: list of filenames
:type filter: str
:param filter: the list of filters that can be applied. The first extension
is used. The format is
``"Filetype1 (*.ex1);;Filetype2 (*.ex2 *.ex3)"``
:rtype: list
:return: list of filenames in the same order as files. Each item in the
returned list is unchanged if it had an extension, or has an extension
added to it if it did not. The added extension is the first one in the
selected filter list of dialog.
Examples filters::
"Image files (*.png *.jpg *.bmp)"
"Images (*.png *.xpm *.jpg);;Text files (*.txt);;All files (*)"
"""
# Extract the first filter extension
try:
chunk = filter.split('(')[1]
except (IndexError, AttributeError):
# No extension listed in filter
return
chunk = chunk.split(')')[0]
chunk = chunk.split()[0]
extension = chunk.replace('*', "")
new_filenames = []
for afile in files:
if not os.path.splitext(afile)[1]:
afile = afile + extension
new_filenames.append(afile)
return new_filenames
[docs]def get_save_file_name(parent="",
caption='Save File',
dir=None,
filter='All Files (*)',
accept_label='Save',
accept_mode=QtWidgets.QFileDialog.AcceptSave,
file_mode=QtWidgets.QFileDialog.AnyFile,
**kwargs):
"""
Convenience function that returns a filename as selected by the user
The base class `base_file_dialog` documents the keyword arguments for this
class.
:rtype: str or None
:return: full pathname of the file selected by the user, or None if Cancel
was pressed
"""
files = base_file_dialog(parent=parent,
caption=caption,
dir=dir,
filter=filter,
accept_label=accept_label,
accept_mode=accept_mode,
file_mode=file_mode,
**kwargs)
#Ignore adding extension to files if the cancel button was used
if files:
files = _add_extension_if_necessary(files, _last_selected_filter)
try:
return files[0]
except (IndexError, TypeError):
return files
[docs]def get_open_file_names(parent="",
caption='Open Files',
dir=None,
filter='All Files (*)',
accept_label='Open',
file_mode=QtWidgets.QFileDialog.ExistingFiles,
**kwargs):
"""
Convenience function that returns a list of filenames as selected by the
user
The base class `base_file_dialog` documents the keyword arguments for this
class.
:rtype: list of str or None
:return: list of full file pathnames selected by the user, or None if Cancel
was pressed.
"""
files = base_file_dialog(parent=parent,
caption=caption,
dir=dir,
filter=filter,
accept_label=accept_label,
file_mode=file_mode,
**kwargs)
return files
[docs]def get_open_file_name(parent="",
caption='Open File',
dir=None,
filter='All Files (*)',
**kwargs):
"""
Convenience function that returns a single filename as selected by the
user
The base class `base_file_dialog` documents the keyword arguments for this
class.
:rtype: str or None
:return: full pathname of the file selected by the user, or None if Cancel
was pressed
"""
files = base_file_dialog(parent=parent,
caption=caption,
dir=dir,
filter=filter,
**kwargs)
try:
return files[0]
except (IndexError, TypeError):
return files
[docs]def get_open_wm_file_name(parent="", dir=None, **kwargs):
"""
Convenience function that returns a single WaterMap file as selected by
the user.
See `base_file_dialog` for documentation.
:return: Full pathname of the WaterMap file selected by
the user or None if cancel was pressed.
:rtype: str or None
"""
caption = "Please select a WaterMap file."
exts = ["*_wm.mae", "*_wm.maegz", "*_wm.mae.gz"]
filter = "WaterMap files ({0})".format(" ".join(exts))
return get_open_file_name(parent,
caption=caption,
dir=dir,
filter=filter,
**kwargs)
_last_selected_filter = None
"""The last filter chosen in a file dialog"""
_last_selected_directory = {}
""" Dictionary - keys are browser ID's and values are the last directory for
that id """
_history_by_id = {}
""" Dictionary - keys are browser ID's and values are the dialog's history """
[docs]def get_last_selected_directory(idval):
"""
Return the last directory selected by a user in a dialog with the given id
value. If there is no entry for the given id value, None is returned.
:type idval: str, int or float
:param idval: The value passed to a filedialog using the id keyword argument
:rtype: str or None
:return: The last directory opened by a dialog with the given id value, or
None if no entry exists for the id value.
"""
return _last_selected_directory.get(idval)
[docs]def base_file_dialog(parent="",
caption='Open File',
dir=None,
filter='All Files (*)',
selectedFilter=None,
options=None,
default_suffix=None,
default_filename=None,
accept_label='Open',
accept_mode=QtWidgets.QFileDialog.AcceptOpen,
file_mode=QtWidgets.QFileDialog.ExistingFile,
confirm=True,
custom_sidebar=True,
sidebar_links=None,
id=None,
*,
show_dirs_only=False):
"""
Convenience function that creates a highly customizable file dialog
:type parent: qwidget
:param parent: the widget over which this dialog should be shown.
:type caption: str
:param caption: the name that appears in the titlebar of this dialog.
:type dir: str
:param dir: the initial directory displayed in this dialog. If id keyword
is also supplied, subsequent calls will open in the last opened
directory, which can be different from dir.
:type filter: str
:param filter: the list of filters that can be applied to this directory.
the format is ``"Filetype1 (*.ex1);;Filetype2 (*.ex2 *.ex3)"``.
:type selectedFilter: str
:param selectedFilter: the filter used by default. if not specified, the
first filter in the filters string is used.
:type options: qfiledialog.option enum
:param options: see the qfiledialog.option and qfiledialog.setoptions
documentation
:type default_suffix: str
:param default_suffix: the suffix applied to a filename if the user does
supply one. the suffix will have a leading '.' appended to it.
:type default_filename: str
:param default_filename: A default base filename to use to save files in
save dialogs. By default, the filename field of the dialog is blank.
:type accept_label: str
:param accept_label: the text on the 'accept' button
:type accept_mode: qfiledialog.acceptmode
:param accept_mode: whether the dialog is in open or save mode. see the
pyqt documentation for qfiledialog.acceptmode (currently acceptopen and
acceptsave are the two options)
:type file_mode: qfiledialog.filemode
:param file_mode: what the user can select. see the pyqt documentation for
qfiledialog.filemode (currently anyfile, existingfile, directory and
existingfiles are the options)
:type confirm: bool
:param confirm: true if a confirmation dialog should be used if the user
selects an existing file, false if not
:type custom_sidebar: bool
:param custom_sidebar: True if the Schrodinger sidebar should be used,
False if the default PyQt sidebar should be used.
:type sidebar_links: dict
:param sidebar_links: Used to create extra links in the left-hand sidebar of
the dialog. The keys of the dictionary are a unique identifier for each
link (note that 'home' and 'working' are already used), and the values are
tuples of the form (path, name) where path and name are str, path indicates
the path the sidebar link points to, and name is the name displayed for the
link.
:type id: str, int or float
:param id: The identifier used for this dialog. Dialogs with the same
identifier will remember the last directory chosen by the user with any
dialog of the same id and open in that directory. The dir keyword parameter
can be used to override the initial directory the dialog opens in, but the
chosen directory will still be stored for the next time a dialog with the
same identifier opens. The default (no id given) is to not remember the
chosen directory. Additionally, the id is used to keep track of
recent places for the given file dialog.
:type show_dirs_only: bool
:param show_dirs_only: Whether we should show only directories in the file
dialog. If False, both files and directories will be shown.
:rtype: list or None
:return: list of full file pathnames selected by the user, or none if cancel
was pressed. Note that all pathnames are strings, and have been converted
to platform-consistent pathnames via os.path.normpath.
"""
global _last_selected_filter
mydir = '.'
if dir is not None:
mydir = dir
if id is not None:
mydir = _last_selected_directory.get(id, mydir)
dialog = FileDialog(parent=parent,
caption=caption,
directory=mydir,
filter=filter,
custom_sidebar=custom_sidebar,
sidebar_links=sidebar_links)
if default_filename:
dialog.selectFile(default_filename)
dialog.setAcceptMode(accept_mode)
dialog.setFileMode(file_mode)
if show_dirs_only:
dialog.setOption(QtWidgets.QFileDialog.ShowDirsOnly, True)
# Apply the user options
if not parent or not caption:
# The QFileDialog class does not apply the other parameters if parent or
# caption is not set.
if not parent and caption:
dialog.setWindowTitle(caption)
dialog.setDirectory(os.path.abspath(mydir))
dialog.setNameFilter(filter)
if selectedFilter:
dialog.selectNameFilter(selectedFilter)
if options is not None:
dialog.setOptions(options)
if default_suffix:
dialog.setDefaultSuffix(default_suffix)
if not confirm:
dialog.setOption(QFileDialog.DontConfirmOverwrite)
dialog.setLabelText(QFileDialog.Accept, accept_label)
if id and _history_by_id.get(id):
dialog.setHistory(_history_by_id.get(id))
ok = dialog.exec()
if ok:
# If not cancel
_last_selected_filter = str(dialog.selectedNameFilter())
files = [os.path.normpath(str(x)) for x in dialog.selectedFiles()]
if files and id is not None:
_last_selected_directory[id] = \
str(dialog.directory().absolutePath())
_history_by_id[id] = dialog.history()
return files
return None
[docs]def fix_splitter(dialog):
"""
Alters the splitter between the file pane and the directory pane so that
both sides are visible. Because Qt saves the state of the dialog, if the
users moves the splitter all the way to one side or the other, all future
dialogs will show up that way, and it can be very confusing if the file side
isn't shown.
:type dialog: `QtWidgets.QFileDialog`
:param dialog: The dialog to check & fix if necessary
"""
try:
splitter = dialog.findChildren(QtWidgets.QSplitter)[0]
except IndexError:
# There should be one splitter in the Dialog - this must be a custom
# Dialog
return
sizes = splitter.sizes()
try:
fileside = sizes[1]
dirside = sizes[0]
except IndexError:
# Not the splitter we wanted - this must be a custom Dialog
return
if not fileside:
sizes = [old_div(dirside, 2), old_div(dirside, 2)]
splitter.setSizes(sizes)
elif not dirside:
sizes = [old_div(fileside, 2), old_div(fileside, 2)]
splitter.setSizes(sizes)
# Do not let file section of dialog collapse (python-1960)
splitter.setCollapsible(1, False)
[docs]class FileDialog(QtWidgets.QFileDialog, CustomSideBarMixin):
"""
File browser dialog with custom sidebar.
This class name was changed from QFileDialog to FileDialog because PyQt on
the Mac OS uses Mac native dialogs instead of the class object if the class
name is QFileDialog.
"""
_pytest_abort_hook = lambda self: None # To prevent showing during tests
getExistingDirectory = staticmethod(get_existing_directory)
getSaveFileName = staticmethod(get_save_file_name)
getOpenFileNames = staticmethod(get_open_file_names)
getOpenFileName = staticmethod(get_open_file_name)
[docs] def __init__(self,
parent=None,
caption="",
directory="",
filter='All Files (*)',
custom_sidebar=True,
sidebar_links=None):
"""
:type parent: qwidget
:param parent: the widget over which this dialog should be shown. If
not given, the Dialog will be placed by PyQt.
:type caption: str
:param caption: the name that appears in the titlebar of this dialog.
If not given the title will be the default PyQt caption.
:type directory: str
:param directory: the initial directory displayed in this dialog,
default is the current directory.
:type filter: str
:param filter: the list of filters that can be applied to this directory
the format is ``"Filetype1 (*.ex1);;Filetype2 (*.ex2 *.ex3)"``.
Default is all files.
:type custom_sidebar: bool
:param custom_sidebar: True if the Schrodinger sidebar should be used,
False if the default PyQt sidebar should be used.
:type sidebar_links: dict
:param sidebar_links: Use to create extra links in the left-hand
sidebar of the dialog. the keys of the dictionary are a unique
identifier for each link (note that 'home' and 'working' are already
used), and the values are tuples of the form (path, name) where path
and name are str, path indicates the path the sidebar link points to,
and name is the name displayed for the link.
"""
# Ev:98084 directory must be absolute path:
directory = os.path.abspath(directory)
if not parent:
QtWidgets.QFileDialog.__init__(self)
elif not caption:
QtWidgets.QFileDialog.__init__(self, parent)
else:
QtWidgets.QFileDialog.__init__(self, parent, caption, directory,
filter)
if custom_sidebar and (not use_native_file_dialog()):
self._setup_sidebar(sidebar_links=sidebar_links)
# Make sure both sides of the file-chooser splitter are visible
fix_splitter(self)
if use_native_file_dialog():
self.setOption(QtWidgets.QFileDialog.DontUseNativeDialog, False)
[docs] def exec(self):
self._pytest_abort_hook()
with qt_utils.remove_wait_cursor:
return super().exec()
# This allows legacy scripts to still access the old class name QFileDialog
QFileDialog = FileDialog
[docs]class CustomFileDialog(FileDialog):
"""
A File Dialog that has all contents below the file view (File name
and File type fields) shifted down a row so that custom controls can be
placed.
"""
[docs] def __init__(self, *args, num_free_rows=1, **kwargs):
super().__init__(*args, **kwargs)
dlg_layout = self.layout()
assert num_free_rows >= 0
fileNameLabel = self.findChild(QtWidgets.QLabel, "fileNameLabel")
dlg_layout.addWidget(fileNameLabel, 2 + num_free_rows, 0)
self.file_name_edit = self.findChild(QtWidgets.QLineEdit,
"fileNameEdit")
dlg_layout.addWidget(self.file_name_edit, 2 + num_free_rows, 1)
buttonBox = self.findChild(QtWidgets.QDialogButtonBox, "buttonBox")
dlg_layout.addWidget(buttonBox, 2 + num_free_rows, 2, 2, 1)
fileTypeLabel = self.findChild(QtWidgets.QLabel, "fileTypeLabel")
dlg_layout.addWidget(fileTypeLabel, 3 + num_free_rows, 0)
self.file_type_combo = self.findChild(QtWidgets.QComboBox,
"fileTypeCombo")
dlg_layout.addWidget(self.file_type_combo, 3 + num_free_rows, 1)
################################################
## Below code is specific to Maestro projects ##
################################################
[docs]class OpenDirAsFileDialog(MM_QProjS):
"""
A file dialog tailored to allow the user to "open" directories such as
projects or phase databases as if they were files.
Usage::
dlg = OpenDirAsFileDialog()
project_path = dlg.getFilename()
if project_path:
# User accepted the dialog with a directory selection.
"""
[docs] def __init__(self,
parent=None,
caption='Open Project',
directory='.',
accept='Open',
filter=MM_QProjS.MM_QPROJS_MAESTRO,
label='Project:'):
"""
:param QWidget parent: Dialog parent.
:param str caption: Dialog
:param str directory: Directory to open in the dialog.
:param str accept: Text for dialog accept button.
:type filter: Directory filter (MM_QPROJS_MAESTRO, MM_QPROJS_CANVAS,
MM_QPROJS_PHDB).
:param filter: MM_QProjS.MMENUM_QPROJS_APP
:param str label: Text for dialog file name label.
"""
cloud_warning = not use_native_file_dialog()
super().__init__(parent, filter, use_native_file_dialog(),
cloud_warning)
self.setWindowTitle(caption)
self.setAcceptOpen()
self.setLabelText(self.Accept, accept)
self.setLabelText(self.FileName, label)
self.setDir(os.path.abspath(directory))
[docs] def getFilename(self):
"""
Open the dialog, allow the user to choose the directory and return the
path.
:return: Path to directory if dialog accepted else None.
:rtype: str or NoneType.
"""
with qt_utils.remove_wait_cursor:
if self.exec():
# FIXME MAE-45189: On Windows - `getSelectedFullPath` is
# returning path with both forward and backslash separators.
return self.getSelectedFullPath().replace('\\', '/')
else:
return None
[docs]class ProjectOpenDialog(OpenDirAsFileDialog):
"""
A file dialog tailored to opening Projects.
"""
[docs] def __init__(self,
parent=None,
caption='Open Project',
directory='.',
accept='Open',
filter=MM_QProjS.MM_QPROJS_MAESTRO,
label='Project:'):
# See OpenDirAsFileDialog.__init__ for documentation.
super().__init__(parent=parent,
caption=caption,
directory=directory,
accept=accept,
filter=filter,
label=label)
[docs]def get_existing_project_name(*args, **kwargs):
"""
Convenience function to open a Open Project dialog and return the path the
user selects.
Parameters are passed to and documented in the `ProjectOpenDialog` class.
:type id: str, int or float
:param id: The identifier used for this dialog. Dialogs with the same
identifier will remember the last directory chosen by the user
with any dialog of the same id and open in that directory. The
dir keyword parameter can be used to override the initial
directory the dialog opens in, but the chosen directory will
still be stored for the next time a dialog with the same
identifier opens. The default (no id given) is to not remember
the chosen directory.
:rtype: str or None
:return: The path to the project if the user selects one, or None if the
user cancels the dialog
"""
# Restore the correct starting directory if requested
id = kwargs.pop('id', None)
if id is not None:
mydir = _last_selected_directory.get(id, '.')
kwargs['directory'] = mydir
mydialog = ProjectOpenDialog(*args, **kwargs)
afile = mydialog.getFilename()
# Save the ending directory if requested
if afile and id is not None:
_last_selected_directory[id] = str(mydialog.directory().absolutePath())
return afile
###############################################
## Below code is specific to Phase databases ##
###############################################
[docs]class PhaseDatabaseOpenDialog(OpenDirAsFileDialog):
"""
A file dialog for opening phase databases.
"""
[docs] def __init__(self,
parent=None,
caption='Open Phase database',
directory='.',
accept='Open',
filter=MM_QProjS.MM_QPROJS_PHDB,
label='Project:'):
# See OpenDirAsFileDialog.__init__ for documentation.
super().__init__(parent=parent,
caption=caption,
directory=directory,
accept=accept,
filter=filter,
label=label)
[docs]def get_existing_phase_database(*args, **kwargs):
"""
Convenience function to open an Open Project dialog and return the path the
user selects.
All parameters are passed to and documented in the PhaseDatabaseOpenDialog
class.
:type id: str, int or float
:param id: The identifier used for this dialog. Dialogs with the same
identifier will remember the last directory chosen by the user
with any dialog of the same id and open in that directory. The
dir keyword parameter can be used to override the initial
directory the dialog opens in, but the chosen directory will
still be stored for the next time a dialog with the same
identifier opens. The default (no id given) is to not remember
the chosen directory.
:rtype: str or None
:return: The path to the project if the user selects one, or None if the
user cancels the dialog
"""
# Restore the correct starting directory if requested
id = kwargs.pop('id', None)
if id is not None:
mydir = _last_selected_directory.get(id, '.')
kwargs['directory'] = mydir
mydialog = PhaseDatabaseOpenDialog(*args, **kwargs)
afile = mydialog.getFilename()
# Save the ending directory if requested
if afile and id is not None:
_last_selected_directory[id] = str(mydialog.directory().absolutePath())
return afile
# For testing purposes:
if __name__ == '__main__':
app = QtWidgets.QApplication(sys.argv)
mydialog = PhaseDatabaseOpenDialog()
print(mydialog.getFilename())
#EOF