Source code for vdat.gui.tabs.ifu_viewer

# Virus Data Analysis Tool: a data reduction GUI for HETDEX/VIRUS data
# Copyright (C) 2015, 2016, 2017, 2018  "The HETDEX collaboration"
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program.  If not, see <https://www.gnu.org/licenses/>.
from __future__ import (absolute_import, division, print_function,
                        unicode_literals)

from collections import namedtuple
from functools import partial
import logging
from os.path import basename
import time
import traceback

from astropy.io import fits
# TODO: remove if not necessary
# import ginga.toolkit
# ginga.toolkit.use('qt4')
from ginga import AstroImage
from ginga.qtw.ImageViewCanvasQt import ImageViewCanvas
from ginga.misc.Settings import SettingGroup
import numpy as np
from qtpy import QtCore, QtGui, QtWidgets

from vdat.gui.menus_actions import QuitAction, HelpMenu
from vdat.gui.utils import wait_cursor
from vdat.utilities import grouper

try:  # pragma: no cover
    import pyds9
except Exception as e:  # pragma: no cover
    pyds9 = None
    pyds9_error = (str(e), traceback.format_exc())

# if no pyds9 is available, do not the signal and decorator are not set
if pyds9:
    ds9_types = [pyds9.DS9]
else:
    ds9_types = []


[docs]class WindowViewerError(Exception): 'Base exception for the viewer classes' pass
[docs]class FitsViewerTitleTooltipError(WindowViewerError, TypeError): '''Exception raised when the titles and/or tooltips for the fits viewer are malformed''' pass
[docs]class DistViewerFileNumberError(FitsViewerTitleTooltipError): '''Exception raised when the number of files, the titles and/or tooltips for the dist viewer are malformed''' pass
[docs]class DS9Menu(QtWidgets.QMenu): '''Menu item with the ``ds9`` actions. .. list-table:: **Custom signals** :header-rows: 1 * - Name - Signature - Description * - :attr:`sig_ds9` - :class:`pyds9.DS9` - emitted, with the ds9 instance to use, when clicking on a ds9 menu option .. list-table:: **Custom slot** :header-rows: 1 * - Name - Signature - Description * - :meth:`populate_ds9_menu` - - dynamically create the ds9 menu * - :meth:`pyds9_error_popup` - - launch an pop up window with the explanation of the pysd9 import failure * - :meth:`launch_ds9` - :class:`PyQt5.QtWidgets.QAction`, optional - Launch DS9. If no action is passed, create a new window, otherwise add a new frame into an existing window; then emit :attr:`sig_ds9` .. list-table:: **Connections between custom signals and/or slots**. :header-rows: 1 * - Signal - Slot * - :attr:`aboutToShow` - :meth:`populate_ds9_menu` * - 'There was a problem loading pyds9.' menu entry - :meth:`pyds9_error_popup` * - Open in DS9 menu entries - :meth:`launch_ds9` Parameters ---------- title : string, optional name of the menu new_ds9_name : list of strings, optional list of string that are concatenated to create a name for a new DS9 window. If nothing is provided, a name will be created new_ds9_menu : bool, optional add a menu to create a new DS9 window parent : :class:`PyQt5.QtWidgets.QWidget` or derivate parent object of the tree view model Attributes ---------- sig_ds9 ''' sig_ds9 = QtCore.Signal(*ds9_types) def __init__(self, title='ds9', new_ds9_name=None, new_ds9_menu=True, parent=None): super(DS9Menu, self).__init__(title, parent=parent) self._new_ds9_name = new_ds9_name self._new_ds9_menu = new_ds9_menu self.aboutToShow.connect(self.populate_ds9_menu) self.error_box = QtWidgets.QErrorMessage(parent=self)
[docs] @QtCore.Slot() def populate_ds9_menu(self): """Fill the DS9 menu of the IFU viewer. If ds9 instances are running, list them underneath the 'Send selected to' tab. Connect these so that on click the selected files are sent to that ds9 instance. """ self.clear() # Clear existing stuff from the menu if not pyds9: self.addAction("pyds9 is not installed or is broken. Click here" " for more details.", self.pyds9_error_popup) return if self._new_ds9_menu: self.addAction("Send selected to a new ds9 window", self.launch_ds9) # if other instances of ds9 running, list them targets = pyds9.ds9_targets() if targets: targets_menu = QtWidgets.QMenu("Send selected to", parent=self) self.addMenu(targets_menu) action_group = QtWidgets.QActionGroup(targets_menu) for target in targets: action_group.addAction(target) action_group.triggered.connect(self.launch_ds9) targets_menu.addActions(action_group.actions())
[docs] @QtCore.Slot() def pyds9_error_popup(self): '''Create an error popup with the pyds9 message. This method is also a PyQt slot. ''' self.error_popup(pyds9_error)
[docs] def error_popup(self, error): """Create a popup reporting the error. Parameters ---------- error : list error[0]: short error message, set as informative text; error[1]: full traceback, set as detailed text """ box = QtWidgets.QMessageBox(parent=self) text = ("There has been a problem while importing pyds9:\n" " {}\n" "However, VDAT will continue working but you cannot push the" " selected fits files to ds9.") box.setText(text.format(error[0])) informative_text = ('To install pyds9 run either one of the following' ' commands:\n' ' pip install vdat[ds9]\n' ' pip install pyds9\n') box.setInformativeText(informative_text) box.setIcon(QtWidgets.QMessageBox.Warning) box.setDetailedText(error[1]) box.exec_()
[docs] @QtCore.Slot() @QtCore.Slot(QtWidgets.QAction) def launch_ds9(self, action=None): """Send files to DS9. If action is specified send to the DS9 window associated to it. Otherwise, create a new DS9 window with an automatically generated name. Parameters ---------- action : :class:`PyQt5.QtWidgets.QAction`, optional action clicked; if provided its text is used as the DS9 instance name """ name = self._ds9_name(action) # launch DS9 try: ds9 = pyds9.DS9(name) except Exception as e: log = logging.getLogger('logger') log.error("Cannot launch ds9: {:}".format(e)) # create a error box to report the error msg = ("An error has being detected while trying to create or" " connect to the ds9 instance: {}<br><br>".format(name)) msg += "The error says:<br>{}<br>".format(e) msg += "<br>" msg += traceback.format_exc().replace('\n', '<br>') self.error_box.showMessage(msg, "ds9-{}".format(type(e))) return self.sig_ds9.emit(ds9)
[docs] def _ds9_name(self, action): '''Get the name from the action or create a new name, if action is ``None``. Parameters ---------- action : :class:`PyQt5.QtWidgets.QAction`, optional action clicked; if provided its text is used as the DS9 instance name Returns ------- name : string name of the ds9 windows ''' if action: name = action.text() else: if self._new_ds9_name: name = '{}'.format('-'.join(self._new_ds9_name)) else: name = 'vdat-fits-viewer-window' # if the DS9 instance already exists, append a number to the name i = 1 targets = pyds9.ds9_targets() if targets: tname = name while any(tname in s for s in targets): tname = name + "-{:d}".format(i) i = i + 1 name = tname return name
[docs]class FitsViewerTab(QtWidgets.QWidget): '''Widget that display a fits file using Ginga in the upper part and the file header on the lower part. Parameters ---------- fname : string name of the file to show tab_dict : dictionary dictionary with the specifications to use to build the tabs parent : :class:`PyQt5.QtWidgets.QWidget` or derivate parent object of the tree view model Attributes ---------- fname : string name of the file displayed in the tab ''' def __init__(self, fname, tab_dict, parent=None): super(FitsViewerTab, self).__init__(parent=parent) self.fname = fname layout = QtWidgets.QVBoxLayout() self.setLayout(layout) splitter = QtWidgets.QSplitter(QtCore.Qt.Vertical, parent=self) layout.addWidget(splitter) # Ginga viewer ginga_widget = GingaWidget(fname, tab_dict, parent=self) header_widget = HeaderWidget(fname, tab_dict, parent=self) # header keywords splitter.addWidget(ginga_widget) splitter.addWidget(header_widget) splitter.setSizes([500, 100]) splitter.setStretchFactor(splitter.indexOf(ginga_widget), 1) splitter.setStretchFactor(splitter.indexOf(header_widget), 0)
[docs]class FitsViewerWindow(QtWidgets.QMainWindow): '''Display the given files using a ginga based viewer. Show the header information and allow to open files in DS9. .. list-table:: **Custom slot** :header-rows: 1 * - Name - Signature - Description * - :meth:`send_to_ds9` - :class:`pyds9.DS9` - Push the file to the ds9 window passed as argument .. list-table:: **Connections between custom signals and/or slots**. :header-rows: 1 * - Signal - Slot * - :attr:`DS9Menu.sig_ds9` - :meth:`send_to_ds9` Parameters ---------- file_names : list list of files to display tab_dict : dictionary dictionary with the specifications to use to build the tabs new_ds9_name : list of strings, optional list of string that are concatenated to create a name for a new DS9 window. If nothing is provided, a name will be created titles, tooltips : list of strings, optional if given they are used as title page and tooltip, respectively, for each of the tabs shown. If ``None`` the file names are used. Otherwise they must be a list with the same length of ``file_names`` parent : :class:`PyQt5.QtWidgets.QWidget` or derivate parent object of the tree view model Attributes ---------- ds9_menu : :class:`PyQt5.QtWidgets.QMenu` menu containing the ds9 buttons tab_dict : dictionary same as input current_tab ''' def __init__(self, file_names, tab_dict, new_ds9_name=None, titles=None, tooltips=None, parent=None): super(FitsViewerWindow, self).__init__(parent=parent) # this variable is saved here and used in :meth:`make_menubar` self._new_ds9_name = new_ds9_name self.tab_dict = tab_dict menu_bar = self.make_menubar() self.setMenuBar(menu_bar) main_widget = self.make_main_widget(file_names, tab_dict, titles, tooltips) self.setCentralWidget(main_widget) @QtCore.Property(FitsViewerTab, doc='''Tab currently shown''') def current_tab(self): return self.centralWidget().currentWidget()
[docs] def make_menubar(self): '''Create a menu bar with a ds9 button.''' menu_bar = QtWidgets.QMenuBar(self) file_menu = menu_bar.addMenu("File") quit_action = QuitAction(connect_to=self.close, parent=self) file_menu.addAction(quit_action) # set up a menu to control DS9 # ds9_menu attribute is saved and used in populate_ds9_menu self.ds9_menu = DS9Menu(title='ds9', new_ds9_name=self._new_ds9_name, parent=menu_bar) menu_bar.addMenu(self.ds9_menu) self.ds9_menu.sig_ds9.connect(self.send_to_ds9) # Add a link to ginga reference page help_menu = HelpMenu(parent=menu_bar, windows_parent=self) icon = QtGui.QIcon.fromTheme('help-contents') self._ginga_link = QtWidgets.QAction(icon, "Ginga Quick Reference (online)", help_menu) self._ginga_link.triggered.connect(self.show_ginga_help) # insert the ginga help on top of the actions and then a separator first_action = help_menu.actions()[0] help_menu.insertAction(first_action, self._ginga_link) sep = QtWidgets.QAction(help_menu) sep.setSeparator(True) help_menu.insertAction(first_action, sep) menu_bar.addMenu(help_menu) return menu_bar
[docs] @QtCore.Slot() def show_ginga_help(self): '''Open the ginga url into a browser. Ths method is also a PyQt slot ''' url = "http://ginga.readthedocs.io/en/latest/quickref.html" QtGui.QDesktopServices.openUrl(QtCore.QUrl(url))
[docs] @QtCore.Slot(*ds9_types) def send_to_ds9(self, ds9): """Send files to DS9. If action is specified send to the DS9 window associated to it. Otherwise, create a new DS9 window with an automatically generated name. Parameters ---------- ds9 : :class:`pyds9.DS9` ds9 instance to where the data are sent """ # file to open in DS9 file_name = self.current_tab.fname # If the extension is present in the ``tab_dict``, send only the # extension to DS9 if 'ext' in self.tab_dict: file_name += '[{e}]'.format(e=self.tab_dict['ext']) # send selected files to DS9 if ds9.get('file'): ds9.set('frame new') ds9.set("file {:s}".format(file_name)) ds9.set("scale mode zscale") ds9.set("tile yes grid mode")
[docs] def make_main_widget(self, file_names, tab_dict, titles, tooltips): '''Create a tab widget and add :class:`FitsViewerTab` classes as tabs. Parameters ---------- file_names : list of strings files to display tab_dict : dictionary dictionary with the specifications to use to build the tabs titles, tooltips : list of strings or None if None, use the file names, otherwise check the length is the same of ``file_names`` Returns ------- tab_widget : :class:`PyQt5.QtWidgets.QTabWidget` widget to plug as main in the window ''' tab_widget = QtWidgets.QTabWidget(parent=self) fnames, titles, tooltips = self._make_title_tooltip(file_names, titles, tooltips) for fn, title, tt in zip(fnames, titles, tooltips): tab = FitsViewerTab(fn, tab_dict, parent=self) index = tab_widget.addTab(tab, title) tab_widget.setTabToolTip(index, tt) return tab_widget
[docs] def _make_title_tooltip(self, file_names, titles, tooltips): '''Prepare the titles and tooltips for the tabs Parameters ---------- file_names : list of strings files to display titles, tooltips : list of strings or None if None, use the file names, otherwise check the length is the same of ``file_names`` Returns ------- file_names, titles, tooltips : list of strings if titles and tooltips are ``None`` return lists with default values Raises ------ FitsViewerTitleTooltipError when titles or tooltips is malformed ''' if titles is None: titles = [basename(fn) for fn in file_names] ext = self.tab_dict.get('ext') if ext is not None: titles = [t + '[{e}]'.format(e=ext) for t in titles] else: if len(file_names) != len(titles): msg = ('``titles`` must be either ``None`` or' ' a list with the same length of ``file_names``') raise FitsViewerTitleTooltipError(msg) if tooltips is None: tooltips = file_names[:] else: if len(file_names) != len(tooltips): msg = ('``tooltips`` must be either ``None`` or' ' a list with the same length of ``file_names``') raise FitsViewerTitleTooltipError(msg) return file_names, titles, tooltips
[docs]class GingaWidget(QtWidgets.QWidget): '''Widget that display a fits file using Ginga. Parameters ---------- fname : string name of the file to show tab_dict : dictionary dictionary with the specifications to use to build the tabs parent : :class:`PyQt5.QtWidgets.QWidget` or derivate parent object of the tree view model ''' def __init__(self, fname, tab_dict, parent=None): super(GingaWidget, self).__init__(parent=parent) self.setMinimumSize(500, 500) self._first_shown = True self.tab_dict = tab_dict layout = QtWidgets.QVBoxLayout() self.setLayout(layout) self.fits_viewer = self.ginga_panel(fname) fits_widget = self.fits_viewer.get_widget() layout.addWidget(fits_widget)
[docs] def ginga_panel(self, fn): """Setups up a Ginga fits viewer panel. Parameters ---------- fn : string the filename of the fits file to view Returns ------- fitsview : :class:`~ginga.qtw.ImageViewCanvasQt.ImageViewCanvasQt` """ glog = logging.getLogger('ginga') ginga_settings = SettingGroup(logger=glog) # Set up a Ginja fits viewer fitsview = ImageViewCanvas(glog, render='widget', settings=ginga_settings) fitsview.enable_autocuts('on') fitsview.set_autocut_params('zscale') fitsview.enable_autozoom('override') # load the fits image image = self.load_fits_image(fn, glog) fitsview.set_window_size(*image.get_size()) fitsview.set_image(image) fitsview.image_filename = fn fitsview.ui_setActive(True) # enable all the bells and whistles (keyboard/mouse shortcuts) bd = fitsview.get_bindings() bd.enable_all(True) return fitsview
[docs] def showEvent(self, event): '''When the tab becomes visible, this method is triggered by Qt. If it is the first time it is shown, zoom the image to fit. Parameters ---------- event : :class:`PyQt5.QtGui.QShowEvent` the tab is show ''' if self._first_shown: self.fits_viewer.zoom_fit() self._first_shown = False
[docs] def load_fits_image(self, fn, log): """Load a FITS file as a Ginga AstroImage and return it Parameters ---------- fn : String the filename of the image to load log : a logger Returns ------- image : :class:`~ginga.AstroImage.AstroImage` instance """ image = AstroImage.AstroImage(logger=log) ext = self.tab_dict.get('ext', 0) with fits.open(fn, memmap=False) as hdul: hdu = hdul[ext] # if the underlying image is a cube, show the median of the given # slice as done in the focal plane if hdu.data.ndim == 3: data = hdu.data z_indx = self.tab_dict.get('z_indx', (None, None)) data = np.nanmedian(data[z_indx[0]:z_indx[1], ...], axis=0) hdu = fits.PrimaryHDU(data) image.load_hdu(hdu) log.debug("Loaded image {:}".format(fn)) return image
[docs]class HeaderWidget(QtWidgets.QTextEdit): '''Widget that display a fits file headers. Parameters ---------- fname : string name of the file to show tab_dict : dictionary dictionary with the specifications to use to build the tabs parent : :class:`PyQt5.QtWidgets.QWidget` or derivate parent object of the tree view model ''' def __init__(self, fname, tab_dict, parent=None): super(HeaderWidget, self).__init__(parent=parent) self.setReadOnly(True) self.setText(self.parse_fits_header(fname, tab_dict)) self.setMinimumHeight(100)
[docs] def parse_fits_header(self, fn, tab_dict): """Convert a FITS header to a string and return it. The method uses directly the following keys of the ``tab_dict`` argument: * ``header_keys`` (optional): list of strings. Header keywords to show on top of the others in the fits viewer window. Parameters ---------- fn : string the filename of the fits file tab_dict : dictionary dictionary with the specifications to use to build the tabs Returns ------- ostr : string The header information """ header = fits.getheader(fn) ostr = header.tostring(sep='\n', endcard=False, padding=False).strip() header_keys = tab_dict.get('header_keys', []) if header_keys: ostr = self._order_header_keys(ostr, header_keys) return ostr
[docs] def _order_header_keys(self, istr, header_keys): '''Split the incoming string on new lines and move to the beginning all the lines starting with a keyword in header_keys. Parameters ---------- istr : string string with the full headers header_keys : list of strings header keywords to put at the beginning Returns ------- ostr : string string reorganized ''' move_line = namedtuple('move_line', 'line, line_index, key_index') header_keys = [i.upper() for i in header_keys] lines = istr.split('\n') to_move = [] for i, l in enumerate(lines): key = l.split('=')[0].strip() if key in header_keys: index = header_keys.index(key) to_move.append(move_line(line=l, line_index=i, key_index=index)) to_move.sort(key=lambda x: x.key_index) on_top = [i.line for i in to_move] for i in to_move: lines.pop(i.line_index) if on_top and lines: on_top.append('-'*len(on_top[-1])) ostr = on_top + lines ostr = '\n'.join(ostr) return ostr
[docs]class FileEditorWindow(QtWidgets.QMainWindow): '''Simple single file editor window. This is loosely based on https://www.binpress.com/tutorial/building-a-text-editor-with-pyqt-part-one/143 .. list-table:: **Custom signals** :header-rows: 1 * - Name - Signature - Description * - :attr:`sig_saved` - str - emitted when a file is emitted .. list-table:: **Custom slot** :header-rows: 1 * - Name - Signature - Description * - :meth:`set_title` - str - set the window title with the given file name * - :meth:`mark_not_modified` - str, optional - mark the window as not modified, input parameters ignored * - :meth:`save_file` - str, optional - save the document * - :meth:`save_file_as` - - save the document as a new file .. list-table:: **Connections between custom signals and/or slots**. :header-rows: 1 * - Signal - Slot * - :attr:`vdat.gui.menus_actions.QuitAction.triggered` - :meth:`close` * - :attr:`save_action.triggered` - :meth:`save_file` * - :attr:`save_as_action.triggered` - :meth:`save_file_as` * - :attr:`sig_saved` - :meth:`set_title` * - :attr:`sig_saved` - :meth:`mark_not_modified` Parameters ---------- file_names : string file to display tab_dict : dictionary dictionary with the specifications to use to build the tabs parent : :class:`PyQt5.QtWidgets.QWidget` or derivate parent object of the tree view model Attributes ---------- file_name : string name of the file to display text_edit : :class:`PyQt5.QtWidgets.QTextEdit` display the text save_action : :class:`PyQt5.QtWidgets.QAction` action to save the document save_as_action : :class:`PyQt5.QtWidgets.QAction` action to save the document under a new name ''' sig_saved = QtCore.Signal(str) def __init__(self, file_name, tab_dict, parent=None): super(FileEditorWindow, self).__init__(parent=parent) self.file_name = file_name self.setMinimumSize(600, 500) self.set_title(file_name) self.make_common_actions() self.setMenuBar(self.make_menubar()) self.addToolBar(self.make_toolbar()) self.text_edit = self.make_text_edit() self.setCentralWidget(self.text_edit) self.sig_saved.connect(self.mark_not_modified) self.sig_saved.connect(self.set_title)
[docs] def make_text_edit(self): '''Create a QTextEdit edit and returns it ''' text_edit = QtWidgets.QTextEdit(self) text_edit.setLineWrapMode(QtWidgets.QTextEdit.NoWrap) with open(self.file_name) as f: file_content = f.read() text_edit.setPlainText(file_content) text_edit.textChanged.connect(partial(self.setWindowModified, True)) return text_edit
[docs] def make_common_actions(self): '''Create a number of actions than are plugged into the task and menu bars''' save_icon = QtGui.QIcon.fromTheme('document-save') self.save_action = QtWidgets.QAction(save_icon, 'Save', self) self.save_action.setStatusTip('Save document') self.save_action.setShortcut('Ctrl+S') self.save_action.triggered.connect(self.save_file) save_icon = QtGui.QIcon.fromTheme('document-save-as') self.save_as_action = QtWidgets.QAction(save_icon, 'Save as', self) self.save_as_action.setStatusTip('Save document under a new name') self.save_as_action.setShortcut('Ctrl+Shift+S') self.save_as_action.triggered.connect(self.save_file_as)
[docs] def make_menubar(self): '''Create and return the menu''' menu_bar = QtWidgets.QMenuBar(self) file_menu = menu_bar.addMenu("File") file_menu.addAction(self.save_action) file_menu.addAction(self.save_as_action) file_menu.addSeparator() quit_action = QuitAction(connect_to=self.close, parent=self) file_menu.addAction(quit_action) menu_bar.addMenu(HelpMenu(parent=menu_bar, windows_parent=self)) return menu_bar
[docs] def make_toolbar(self): '''Create and return the toolbar''' tool_bar = QtWidgets.QToolBar('Options', self) tool_bar.addAction(self.save_action) tool_bar.addAction(self.save_as_action) tool_bar.addSeparator() return tool_bar
[docs] @QtCore.Slot(str) def set_title(self, file_name): '''Set the title of the window. This method is also a PyQt slot. Parameters ---------- file_name : string name of the file ''' # Qt automatically put a * when the widget is modified, if the title # contains a [*]: # https://doc.qt.io/qt-4.8/qwidget.html#windowModified-prop self.setWindowTitle('{}[*] -- VDAT'.format(basename(file_name)))
[docs] @QtCore.Slot() @QtCore.Slot(str) def mark_not_modified(self, _=None): '''Mark the window as not modified. The parameter is ignored This method is also a PyQt slot. ''' self.setWindowModified(False)
[docs] @QtCore.Slot() @QtCore.Slot(str) def save_file(self, fname=None): '''Get the text and save it to file. If ``fname`` is ``None``, overwrite the input file. This method is also a PyQt slot Parameters ---------- fname : string, optional name of the file to use ''' if not fname: fname = self.file_name with open(fname, 'w') as f: f.write(self.text_edit.toPlainText()) self.sig_saved.emit(fname)
[docs] @QtCore.Slot() def save_file_as(self): '''Ask for a new file name and save there the file. If no file name is given, nothing happens. This method is also a PyQt slot Parameters ---------- fname : string, optional name of the file to use ''' filename = QtWidgets.QFileDialog.getSaveFileName(self, 'Save File') if not filename: return self.file_name = filename self.save_file(fname=filename)
[docs] def closeEvent(self, event): '''If the file is modified, ask what to do before closing. If ``Cancel`` is pressed, the window is not closed. .. note:: from the `documentation <https://doc.qt.io/qt-5/qwidget.html#close>`_ it seems that it's preferable to reimplement :meth:`closeEvent` and the :meth:`close` method. Parameters ---------- event : :class:`PyQt5.QtGui.QCloseEvent` event emitted when a close attempt is made ''' accept = True if self.isWindowModified(): msg_box = QtWidgets.QMessageBox(self) msg_box.setText("The document has been modified.") msg_box.setInformativeText("Do you want to save your changes?") msg_box.setStandardButtons(QtWidgets.QMessageBox.Save | QtWidgets.QMessageBox.Discard | QtWidgets.QMessageBox.Cancel) msg_box.setDefaultButton(QtWidgets.QMessageBox.Save) reply = msg_box.exec_() # if "Cancel" is pressed, discard the close event accept = (reply != QtWidgets.QMessageBox.Cancel) if reply == QtWidgets.QMessageBox.Save: self.save_file() if accept: event.accept() else: event.ignore()
[docs]class DistTab(QtWidgets.QWidget): '''Widget that display a distortion solution and the region Parameters ---------- dist_name : string name of the distortion file to show reg_name : string name of the region file attached to ``dist_name`` fits_names : list of strings name of the fits file that can be sent to DS9 tab_dict : dictionary dictionary with the specifications to use to build the tabs parent : :class:`PyQt5.QtWidgets.QWidget` or derivate parent object of the tree view model Attributes ---------- dist_name : string same as input reg_name : string same as input fits_names : list of strings same as input ''' def __init__(self, dist_name, reg_name, fits_names, tab_dict, parent=None): super(DistTab, self).__init__(parent=parent) self.dist_name = dist_name self.reg_name = reg_name self.fits_names = fits_names layout = QtWidgets.QVBoxLayout() self.setLayout(layout) text_display = self.dist_text_widget() layout.addWidget(text_display) # splitter = QtGui.QSplitter(QtCore.Qt.Vertical, parent=self) # layout.addWidget(splitter) # splitter.setSizes([500, 100]) # splitter.setStretchFactor(splitter.indexOf(ginga_widget), 1) # splitter.setStretchFactor(splitter.indexOf(header_widget), 0)
[docs] def dist_text_widget(self): '''Create a read-only text area displaying the distortion file Returns ------- text_display : :class:`PyQt5.QtWidgets.QTextEdit` widget showing the dist file ''' text_display = QtWidgets.QTextEdit(self) text_display.setReadOnly(True) with open(self.dist_name) as f: file_content = f.read() text_display.setPlainText(file_content) return text_display
[docs]class DistWindow(QtWidgets.QMainWindow): '''Show the distortion file and the region file and add button to send the distortion region to DS9. .. list-table:: **Custom slot** :header-rows: 1 * - Name - Signature - Description * - :meth:`send_dist_fits_to_ds9` - :class:`pyds9.DS9` - Push the fits files and the regions to the ds9 window passed as argument * - :meth:`send_dist_to_ds9` - :class:`pyds9.DS9` - Push the regions to the ds9 window passed as argument .. list-table:: **Connections between custom signals and/or slots**. :header-rows: 1 * - Signal - Slot * - :attr:`DS9Menu.sig_ds9` (menu 'Distortion and fits') - :meth:`send_dist_fits_to_ds9` * - :attr:`DS9Menu.sig_ds9` (menu 'Distortion only') - :meth:`send_dist_to_ds9` Parameters ---------- dist_files : list of strings distortion files to display reg_files : list of strings region files to display, must have the same size of ``dist_files`` fits_names : 2d list of strings list of list of fits files, the first dimension must have the same size of ``dist_files`` tab_dict : dictionary dictionary with the specifications to use to build the tabs new_ds9_name : list of strings, optional list of string that are concatenated to create a name for a new DS9 window. If nothing is provided, a name will be created titles, tooltips : list of strings, optional if given they are used as title page and tooltip, respectively, for each of the tabs shown. If ``None`` the file names are used. Otherwise they must be a list with the same length of ``dist_files`` parent : :class:`PyQt5.QtWidgets.QWidget` or derivate parent object of the tree view model Attributes ---------- ds9_menu : :class:`PyQt5.QtWidgets.QMenu` menu containing the ds9 buttons current_tab ''' def __init__(self, dist_files, reg_files, fits_files, tab_dict, new_ds9_name=None, titles=None, tooltips=None, parent=None): super(DistWindow, self).__init__(parent=parent) # this variable is saved here and used in :meth:`make_menubar` self._new_ds9_name = new_ds9_name menu_bar = self.make_menubar() self.setMenuBar(menu_bar) main_widget = self.make_main_widget(dist_files, reg_files, fits_files, tab_dict, titles, tooltips) self.setCentralWidget(main_widget) self.setMinimumSize(600, 500) @QtCore.Property(DistTab, doc='''Tab currently shown''') def current_tab(self): return self.centralWidget().currentWidget()
[docs] def make_menubar(self): '''Create a menu bar with a ds9 button.''' menu_bar = QtWidgets.QMenuBar(self) file_menu = menu_bar.addMenu("File") quit_action = QuitAction(connect_to=self.close, parent=self) file_menu.addAction(quit_action) # set up a menu to control DS9 # ds9_menu attribute is saved and used in populate_ds9_menu self.ds9_menu = menu_bar.addMenu('ds9') ds9_menu_fits = DS9Menu(title='Distortion and fits', new_ds9_name=self._new_ds9_name, parent=menu_bar) self.ds9_menu.addMenu(ds9_menu_fits) ds9_menu_fits.sig_ds9.connect(self.send_dist_fits_to_ds9) ds9_menu_dist = DS9Menu(title='Distortion only', new_ds9_name=self._new_ds9_name, new_ds9_menu=False, parent=menu_bar) self.ds9_menu.addMenu(ds9_menu_dist) ds9_menu_dist.sig_ds9.connect(self.send_dist_to_ds9) return menu_bar
[docs] def make_main_widget(self, dist_files, reg_files, fits_files, tab_dict, titles, tooltips): '''Create a tab widget and add :class:`DistTab` classes as tabs. Parameters ---------- dist_files : list of strings distortion files to display reg_files : list of strings region files to display, must have the same size of ``dist_files`` fits_names : 2d list of strings list of list of fits files, the first dimension must have the same size of ``dist_files`` tab_dict : dictionary dictionary with the specifications to use to build the tabs titles, tooltips : list of strings, optional if given they are used as title page and tooltip, respectively, for each of the tabs shown. If ``None`` the file names are used. Otherwise they must be a list with the same length of ``dist_files`` Returns ------- tab_widget : :class:`PyQt5.QtWidgets.QTabWidget` widget to plug as main in the window ''' if len(reg_files) != len(dist_files): msg = ('``reg_files`` must the same length of ``dist_files``') raise DistViewerFileNumberError(msg) if len(fits_files) != len(dist_files): msg = ('``fits_files`` must the same length of ``dist_files``') raise DistViewerFileNumberError(msg) fnames, titles, tooltips = self._make_title_tooltip(dist_files, titles, tooltips) tab_widget = QtWidgets.QTabWidget(parent=self) for fn, reg_fn, fits_fn, title, tt in zip(fnames, reg_files, fits_files, titles, tooltips): tab = DistTab(fn, reg_fn, fits_fn, tab_dict, parent=self) index = tab_widget.addTab(tab, title) tab_widget.setTabToolTip(index, tt) return tab_widget
[docs] def _make_title_tooltip(self, file_names, titles, tooltips): '''Prepare the titles and tooltips for the tabs Parameters ---------- file_names : list of strings files to display titles, tooltips : list of strings or None if None, use the file names, otherwise check the length is the same of ``file_names`` Returns ------- file_names, titles, tooltips : list of strings if titles and tooltips are ``None`` return lists with default values Raises ------ DistViewerFileNumberError when titles or tooltips is malformed ''' if titles is None: titles = [basename(fn) for fn in file_names] else: if len(file_names) != len(titles): msg = ('``titles`` must be either ``None`` or' ' a list with the same length of ``dist_files``') raise DistViewerFileNumberError(msg) if tooltips is None: tooltips = file_names[:] else: if len(file_names) != len(tooltips): msg = ('``tooltips`` must be either ``None`` or' ' a list with the same length of ``dist_files``') raise DistViewerFileNumberError(msg) return file_names, titles, tooltips
[docs] @QtCore.Slot(*ds9_types) def send_dist_fits_to_ds9(self, ds9): """Send the fits files to DS9 and overplot the region files. This method is also a PyQt slot. Parameters ---------- ds9 : :class:`pyds9.DS9` ds9 instance to where the data are sent """ # file to open in DS9 with wait_cursor(): current_tab = self.current_tab for file_name in current_tab.fits_names: # send selected files to DS9 if ds9.get('file'): ds9.set('frame new') # send a fits file ds9.set("file {:s}".format(file_name)) ds9.set("scale mode zscale") ds9.set("tile yes grid mode") self.send_region_to_ds9(ds9, current_tab.reg_name)
[docs] @QtCore.Slot(*ds9_types) def send_dist_to_ds9(self, ds9): """Send the region file to a DS9 window. This method is also a PyQt slot. Parameters ---------- ds9 : :class:`pyds9.DS9` ds9 instance to where the data are sent """ # file to open in DS9 with wait_cursor(): current_tab = self.current_tab self.send_region_to_ds9(ds9, current_tab.reg_name)
[docs] def send_region_to_ds9(self, ds9, regions_file, chunk_size=50): """Send the regions from the region file to DS9. This method assumes that each line is a region command. Because of this it cannot simply load the file into DS9, but have to send the regions in chunks of size ``chunk_size``. Parameters ---------- ds9 : :class:`pyds9.DS9` ds9 instance to where the data are sent regions_file : string name of the file containing the regions chunk_size : int, optional number of region elements to send in one go to DS9 """ # file to open in DS9 with open(regions_file) as f: regions = f.read() # split on new lines regions = regions.splitlines() for r in grouper(regions, n=chunk_size): exc_counter = 0 while True: try: ds9.set(';'.join([i for i in r if i])) except ValueError: if exc_counter > 3: raise else: exc_counter += 2 # if too many stuff are sent to XPA, sometimes it # crashes. Try again and the go ahead time.sleep(0.2) else: break
# the following code can be used to test the windows in isolation # def main(): # import sys # # app = QtGui.QApplication(sys.argv) # # main = FileEditorWindow(sys.argv[1], {}) # main.show() # # sys.exit(app.exec_()) # # # if __name__ == "__main__": # main()