[docs]
class QADMainWindow(QtWidgets.QMainWindow, ui_qad_main.Ui_MainWindow):
"""
QAD Qt5 GUI main window.
All attributes and methods for this class are intended for
internal use, to support the main GUI event loop and operations.
This class is normally instantiated from the
`sofia_redux.pipeline.gui.qad.main` function; all methods are
triggered by user interaction only.
The UI for this application is built in Qt Designer: see the
`designer` folder for the Designer input files; the compiled
Python scripts are in the `ui` module. All `ui_*.py` files
should not be edited manually, as they are automatically generated.
See the `designer/compile_ui` file for the sequence of commands
required to rebuild the UI from Designer files.
"""
def __init__(self):
"""Build the QAD GUI window."""
if not HAS_PYQT6: # pragma: no cover
raise ImportError('PyQt6 package is required for QAD.')
# parent initialization
QtWidgets.QMainWindow.__init__(self)
# set up UI from Designer generated file
self.setupUi(self)
# set up icons
open_icon = self.style().standardIcon(
QtWidgets.QStyle.StandardPixmap.SP_DialogOpenButton)
self.actionOpenDirectory.setIcon(open_icon)
up_icon = self.style().standardIcon(
QtWidgets.QStyle.StandardPixmap.SP_FileDialogToParent)
self.actionGoPrevious.setIcon(up_icon)
next_icon = self.style().standardIcon(
QtWidgets.QStyle.StandardPixmap.SP_ArrowForward)
self.actionGoNext.setIcon(next_icon)
home_icon = self.style().standardIcon(
QtWidgets.QStyle.StandardPixmap.SP_DirHomeIcon)
self.actionGoHome.setIcon(home_icon)
save_icon = self.style().standardIcon(
QtWidgets.QStyle.StandardPixmap.SP_DialogSaveButton)
self.actionSaveSettings.setIcon(save_icon)
imexam_icon = self.style().standardIcon(
QtWidgets.QStyle.StandardPixmap.SP_FileDialogContentsView)
self.actionImExam.setIcon(imexam_icon)
header_icon = self.style().standardIcon(
QtWidgets.QStyle.StandardPixmap.SP_FileDialogInfoView)
self.actionDisplayHeader.setIcon(header_icon)
# Establish signal handler to catch ctrl-C
signal.signal(signal.SIGINT, self.cleanup)
# connect GUI signals to slots
# menu and toolbar
self.actionOpenDirectory.triggered.connect(self.onOpen)
self.actionGoPrevious.triggered.connect(self.onPrevious)
self.actionGoNext.triggered.connect(self.onNext)
self.actionGoHome.triggered.connect(self.onHome)
self.actionImExam.triggered.connect(self.onImExam)
self.actionDisplayHeader.triggered.connect(self.onDisplayHeader)
self.actionSaveSettings.triggered.connect(self.onSaveSettings)
self.actionDisplaySettings.triggered.connect(self.onDisplaySettings)
self.actionPhotometrySettings.triggered.connect(
self.onPhotometrySettings)
self.actionPlotSettings.triggered.connect(
self.onPlotSettings)
# other widgets
self.fileFilterBox.editingFinished.connect(self.onFilter)
self.treeView.doubleClicked.connect(self.onRow)
# set directory model in tree view widget
self.model = QtGui.QFileSystemModel(self)
self.treeView.setModel(self.model)
self.treeView.sortByColumn(0, QtCore.Qt.SortOrder.AscendingOrder)
# for file browser tree:
# default root is current working directory
# default file filter is *.fits
self.file_filter = ['*.fits']
self.fileFilterBox.setText(self.file_filter[0])
self.setFilter()
if len(sys.argv) > 1 and os.path.isdir(sys.argv[1]):
self.rootpath = sys.argv[1].rstrip(os.path.sep)
else:
self.rootpath = os.path.abspath(QtCore.QDir.currentPath())
self.lastpath = []
self.setRoot()
# placeholder for header display
self.headviewer = None
# startup imviewer
self.imviewer = None
self.startupImViewer()
# read settings if available
self.cfg_dir = os.path.join(os.path.expanduser('~'), '.qad')
os.makedirs(self.cfg_dir, exist_ok=True)
if self.imviewer is not None:
disp_cfg = os.path.join(self.cfg_dir, 'display.cfg')
if os.path.isfile(disp_cfg):
config = configobj.ConfigObj(disp_cfg, unrepr=True)
self.imviewer.disp_parameters.update(config.dict())
phot_cfg = os.path.join(self.cfg_dir, 'photometry.cfg')
if os.path.isfile(phot_cfg):
config = configobj.ConfigObj(phot_cfg, unrepr=True)
self.imviewer.phot_parameters.update(config.dict())
plot_cfg = os.path.join(self.cfg_dir, 'plot.cfg')
if os.path.isfile(plot_cfg):
config = configobj.ConfigObj(plot_cfg, unrepr=True)
self.imviewer.plot_parameters.update(config.dict())
# placeholder for imexam runnable
self.imexam_worker = None
# set status bar message
self.setStatus('QAD Ready.')
# override functions
[docs]
def closeEvent(self, event):
"""
Clean up processes, then close the application.
Parameters
----------
event : QEvent
Close event.
"""
self.cleanup()
[docs]
def keyPressEvent(self, event):
"""
Handle keyboard shortcuts.
Parameters
----------
event : QEvent
Keypress event.
"""
if type(event) == QtGui.QKeyEvent:
if (self.treeView.hasFocus()
and event.key() == QtCore.Qt.Key.Key_Return):
self.onRow()
# event handlers
[docs]
def onDisplayHeader(self):
"""
Start up header display window and set text.
This method uses the display settings to determine
which extension(s) to retrieve headers for. If a
particular extension is selected, only that header will be
displayed. If all extensions are selected (either for cube
or multi-frame display), all extension headers will be
displayed.
"""
index = self.treeView.selectionModel().selectedRows()
headers = {}
title = None
for i in index:
fpath = os.path.abspath(self.model.filePath(i))
if os.path.isfile(fpath) and fpath.endswith('.fits'):
try:
hdul = fits.open(fpath)
except (OSError, ValueError, TypeError):
log.error(f'Cannot load {fpath} as FITS; ignoring')
else:
headers[fpath] = []
# check the display settings -- if a particular extension
# is specified, only retrieve its header. Otherwise,
# get them all
try:
exten = self.imviewer.get_extension_param()
except (ValueError, TypeError, IndexError,
AttributeError, KeyError):
exten = 'all'
if str(exten) != 'all':
try:
headers[fpath].append(hdul[exten].header)
except (ValueError, IndexError, TypeError,
AttributeError, KeyError):
log.warning(f'No extension {exten} found for '
f'{fpath}; displaying all headers')
exten = 'all'
if str(exten) == 'all':
for hdu in hdul:
headers[fpath].append(hdu.header)
title = os.path.basename(fpath)
if len(headers) == 0:
self.setStatus("No FITS files selected.")
return
elif len(headers) > 1:
title += '...'
if self.headviewer is None or not self.headviewer.isVisible():
self.headviewer = qad_headview.HeaderViewer(self)
self.headviewer.load(headers)
self.headviewer.show()
self.headviewer.raise_()
self.headviewer.setTitle("Header for: {}".format(title))
self.setStatus("FITS headers displayed.")
[docs]
def onDisplaySettings(self):
"""Set general display parameters from user dialog."""
if self.imviewer is None:
self.setStatus('Cannot modify settings without ImView.')
return
current = self.imviewer.disp_parameters
default = self.imviewer.default_parameters('display')
dialog = qad_dialogs.DispSettingsDialog(self, current, default)
retval = dialog.exec()
if retval == 1:
self.imviewer.disp_parameters = dialog.getValue()
[docs]
def onFilter(self):
"""
Filter displayed files.
Multiple filters may be specified in a comma-separated list.
The wildcard '*' may be used in any filter. An empty filter
box will display all files.
"""
self.file_filter = self.fileFilterBox.text().split(',')
self.file_filter = [str(f).strip() for f in self.file_filter]
if (len(self.file_filter) == 1
and str(self.file_filter[0]).strip() == ''):
self.file_filter[0] = '*'
self.setFilter()
[docs]
def onHome(self):
"""Set the home directory as the root."""
self.rootpath = os.path.expanduser('~')
self.lastpath = []
self.setRoot()
[docs]
def onImExam(self):
"""Start imexam in a new thread."""
self.actionImExam.setEnabled(False)
self.setStatus("Starting imexam in DS9; press 'q' to quit.")
self.imviewer.break_loop = False
threadpool = QtCore.QThreadPool.globalInstance()
self.imexam_worker = GeneralRunnable(self.imviewer.imexam)
self.imexam_worker.signals.finished.connect(self.imexamFinish)
threadpool.start(self.imexam_worker)
[docs]
def imexamFinish(self, status):
"""
ImExam callback.
Parameters
----------
status : None or tuple
If not None, contains an error message to log.
"""
if status is not None:
# log the error
log.error("\n{}".format(status[2]))
self.imexam_worker = None
self.imviewer.break_loop = True
self.setStatus('')
self.actionImExam.setEnabled(True)
[docs]
def onNext(self):
"""Set the last recorded directory as the root."""
if len(self.lastpath) > 0:
self.rootpath = self.lastpath.pop()
self.setRoot()
[docs]
def onOpen(self):
"""Select a new directory as the root."""
newpath = os.path.abspath(
QtWidgets.QFileDialog.getExistingDirectory(
self, 'Select Directory'))
if newpath.strip() != '':
newpath = os.path.normpath(newpath)
self.lastpath = []
self.rootpath = newpath
self.resetModel()
[docs]
def onPhotometrySettings(self):
"""Set parameters for photometry from a user dialog."""
if self.imviewer is None:
self.setStatus('Cannot modify settings without ImView.')
return
current = self.imviewer.phot_parameters
default = self.imviewer.default_parameters('photometry')
dialog = qad_dialogs.PhotSettingsDialog(self, current, default)
retval = dialog.exec()
if retval == 1:
self.imviewer.phot_parameters = dialog.getValue()
[docs]
def onPlotSettings(self):
"""Set parameters for plots from a user dialog."""
if self.imviewer is None:
self.setStatus('Cannot modify settings without ImView.')
return
current = self.imviewer.plot_parameters
default = self.imviewer.default_parameters('plot')
dialog = qad_dialogs.PlotSettingsDialog(self, current, default)
retval = dialog.exec()
if retval == 1:
self.imviewer.plot_parameters = dialog.getValue()
[docs]
def onPrevious(self):
"""Set the enclosing directory as the root."""
if self.rootpath != '/':
self.lastpath.append(self.rootpath)
self.rootpath = os.path.dirname(self.rootpath)
self.setRoot()
[docs]
def onRow(self):
"""Determine the selected files and schedule them for display."""
# check for an open imexam first
if self.imexam_worker is not None:
QtWidgets.QMessageBox.warning(
self, 'Load Files',
'Please quit ImExam before loading new files.')
return
index = self.treeView.selectionModel().selectedRows()
nsel = len(index)
files_to_open = []
if nsel == 1 and os.path.isdir(self.model.filePath(index[0])):
self.lastpath = []
self.rootpath = os.path.abspath(self.model.filePath(index[0]))
self.setRoot()
else:
for i in index:
fpath = self.model.filePath(i)
if os.path.isfile(fpath):
files_to_open.append(os.path.abspath(fpath))
if len(files_to_open) > 0:
self.openFiles(files_to_open)
[docs]
def onSaveSettings(self):
"""
Save current parameters to disk.
Parameters are saved to files in the hidden .qad
folder in the user's home directory. On startup, these
files are read and used as the default QAD settings.
"""
if self.imviewer is None:
self.setStatus('Cannot save settings without ImView.')
return
if self.cfg_dir is None or not os.path.isdir(self.cfg_dir):
log.error('No config directory available; not saving parameters')
return
# display parameters
config = configobj.ConfigObj(self.imviewer.disp_parameters,
unrepr=True)
config.filename = os.path.join(self.cfg_dir, 'display.cfg')
config.write()
# photometry parameters
config = configobj.ConfigObj(self.imviewer.phot_parameters,
unrepr=True)
config.filename = os.path.join(self.cfg_dir, 'photometry.cfg')
config.write()
# plot parameters
config = configobj.ConfigObj(self.imviewer.plot_parameters,
unrepr=True)
config.filename = os.path.join(self.cfg_dir, 'plot.cfg')
config.write()
self.setStatus('Settings saved to {:s}'.format(self.cfg_dir))
[docs]
def cleanup(self, *args):
"""Close the application."""
# make sure any dangling imexam threads close
if self.imviewer is not None:
self.imviewer.break_loop = True
self.imviewer.quit()
QtWidgets.QApplication.quit()
[docs]
def openFiles(self, filelist):
"""
Display selected files.
This method dispatches FITS files, DS9 region files, and
data arrays to the QAD image viewer. Any other files are
opened with the system default application for their file type.
Parameters
----------
filelist : list of str
Full paths to files for display.
"""
fits_files = []
reg_files = []
other_files = []
for fpath in filelist:
if fpath.endswith('.reg'):
# ds9 region files: pass to imviewer
reg_files.append(fpath)
elif fpath.endswith('.fits'):
# FITS files: pass to imviewer
fits_files.append(fpath)
else:
# other files: pass to OS
other_files.append(fpath)
if len(fits_files) > 0:
# make sure imexam loop is broken before new load
if self.imviewer is not None:
self.imviewer.break_loop = True
try:
self.imviewer.load(fits_files, regfiles=reg_files)
self.setStatus('Loaded FITS files')
except (ValueError, AttributeError):
self.setStatus('Cannot open ImViewer')
return
if len(other_files) > 0:
# if not FITS related, try open (for Mac only)
from sys import platform
if platform == 'darwin':
cmd = ['open']
else:
cmd = None
if len(other_files) > 0 and cmd is not None:
for fname in other_files:
try:
subprocess.call(cmd + [fname])
except Exception:
# ignore it if anything goes wrong
pass
[docs]
def setFilter(self):
"""Set the file filter in the TreeView model."""
self.model.setNameFilters(self.file_filter)
self.model.setNameFilterDisables(False)
[docs]
def resetModel(self):
"""Reset the TreeView model."""
self.model = QtGui.QFileSystemModel(self)
self.treeView.setModel(self.model)
self.setFilter()
self.setRoot()
[docs]
def setRoot(self):
"""Set the TreeView model root path."""
# set model root
self.model.setRootPath(self.rootpath)
# set tree root and hide unnecessary columns
self.treeView.setRootIndex(self.model.index(self.rootpath))
self.treeView.hideColumn(1)
self.treeView.hideColumn(2)
# resize to contents
self.treeView.header().setSectionResizeMode(
0, QtWidgets.QHeaderView.ResizeMode.ResizeToContents)
self.treeView.resizeColumnToContents(0)
# enable/disable next button
if len(self.lastpath) > 0:
self.actionGoNext.setEnabled(True)
else:
self.actionGoNext.setEnabled(False)
# set current directory name
self.dirLabel.setText(self.rootpath)
self.dirLabel.repaint()
# set status message
self.setStatus('Opened {:s}'.format(self.rootpath))
[docs]
def setStatus(self, msg):
"""
Set a status message.
Parameters
----------
msg : str
Message to display.
"""
self.statusBar.showMessage(msg, 5000)
[docs]
def startupImViewer(self):
"""Start the DS9 image viewer."""
try:
self.imviewer = qad_imview.QADImView()
except (ValueError, OSError) as e:
log.error(e)
self.setStatus('Cannot start ImViewer')