Example #1
0
    def test_custom_pane(self):

        settings = MagicMock()

        class CustomPreferences(QtWidgets.QWidget):

            def __init__(self, parent=None):

                super(CustomPreferences, self).__init__(parent=parent)

                self.layout = QtWidgets.QFormLayout()

                self.option1 = QtWidgets.QLineEdit()
                self.option2 = QtWidgets.QLineEdit()

                self.layout.addRow("Option 1", self.option1)
                self.layout.addRow("Option 2", self.option2)

                self.setLayout(self.layout)

            def finalize(self):
                settings.OPTION1 = "Monty"
                settings.OPTION2 = "Python"

        preference_panes = [('Custom', CustomPreferences)]

        with patch('glue.config.preference_panes', preference_panes):

            dialog = PreferencesDialog(self.app)
            dialog.show()
            dialog.accept()

            assert settings.OPTION1 == "Monty"
            assert settings.OPTION2 == "Python"
Example #2
0
    def test_theme_autodetect(self):

        # If we don't change anything, settings should be preserved

        with patch('glue.config.settings') as settings:

            settings.FOREGROUND_COLOR = 'white'
            settings.BACKGROUND_COLOR = 'black'
            settings.DATA_COLOR = '0.75'
            settings.DATA_ALPHA = 0.8

            dialog = PreferencesDialog(self.app)
            dialog.show()
            assert dialog.theme == 'White on Black'
            dialog.accept()

            settings.FOREGROUND_COLOR = 'black'
            settings.BACKGROUND_COLOR = 'white'
            settings.DATA_COLOR = '0.35'
            settings.DATA_ALPHA = 0.8

            dialog = PreferencesDialog(self.app)
            dialog.show()
            assert dialog.theme == 'Black on White'
            dialog.accept()
Example #3
0
    def test_settings_change_message(self):

        # Make sure that a SettingsChangeMessage gets emitted when settings
        # change in the dialog

        class TestListener(HubListener):
            def __init__(self, hub):
                hub.subscribe(self,
                              SettingsChangeMessage,
                              handler=self.receive_message)
                self.received = []

            def receive_message(self, message):
                self.received.append(message)

        listener = TestListener(self.app._hub)

        with patch('glue.config.settings') as settings:

            settings.FOREGROUND_COLOR = 'red'
            settings.BACKGROUND_COLOR = (0, 0.5, 1)
            settings.DATA_COLOR = (1, 0.5, 0.25)
            settings.DATA_ALPHA = 0.3

            dialog = PreferencesDialog(self.app)
            dialog.show()
            dialog.foreground = (0, 1, 1)
            dialog.accept()

        assert len(listener.received) == 1
        assert listener.received[0].settings == ('FOREGROUND_COLOR',
                                                 'BACKGROUND_COLOR')
Example #4
0
    def test_settings_change_message(self):

        # Make sure that a SettingsChangeMessage gets emitted when settings
        # change in the dialog

        class TestListener(HubListener):

            def __init__(self, hub):
                hub.subscribe(self, SettingsChangeMessage,
                              handler=self.receive_message)
                self.received = []

            def receive_message(self, message):
                self.received.append(message)

        listener = TestListener(self.app._hub)

        with patch('glue.config.settings') as settings:

            settings.FOREGROUND_COLOR = 'red'
            settings.BACKGROUND_COLOR = (0, 0.5, 1)
            settings.DATA_COLOR = (1, 0.5, 0.25)
            settings.DATA_ALPHA = 0.3

            dialog = PreferencesDialog(self.app)
            dialog.show()
            dialog.foreground = (0, 1, 1)
            dialog.accept()

        assert len(listener.received) == 1
        assert listener.received[0].settings == ('FOREGROUND_COLOR', 'BACKGROUND_COLOR')
Example #5
0
    def test_custom_pane(self):

        settings = MagicMock()

        class CustomPreferences(QtWidgets.QWidget):
            def __init__(self, parent=None):

                super(CustomPreferences, self).__init__(parent=parent)

                self.layout = QtWidgets.QFormLayout()

                self.option1 = QtWidgets.QLineEdit()
                self.option2 = QtWidgets.QLineEdit()

                self.layout.addRow("Option 1", self.option1)
                self.layout.addRow("Option 2", self.option2)

                self.setLayout(self.layout)

            def finalize(self):
                settings.OPTION1 = "Monty"
                settings.OPTION2 = "Python"

        preference_panes = [('Custom', CustomPreferences)]

        with patch('glue.config.preference_panes', preference_panes):

            dialog = PreferencesDialog(self.app)
            dialog.show()
            dialog.accept()

            assert settings.OPTION1 == "Monty"
            assert settings.OPTION2 == "Python"
Example #6
0
    def test_no_change(self):

        # If we don't change anything, settings should be preserved

        with patch('glue.config.settings') as settings:

            settings.FOREGROUND_COLOR = 'red'
            settings.BACKGROUND_COLOR = (0, 0.5, 1)
            settings.DATA_COLOR = (1, 0.5, 0.25)
            settings.DATA_ALPHA = 0.3

            dialog = PreferencesDialog(self.app)
            dialog.show()

            assert dialog.theme == 'Custom'

            dialog.accept()

            assert rgb(settings.FOREGROUND_COLOR) == (1, 0, 0)
            assert rgb(settings.BACKGROUND_COLOR) == (0, 0.5, 1)
            assert rgb(settings.DATA_COLOR) == (1, 0.5, 0.25)
            assert settings.DATA_ALPHA == 0.3
Example #7
0
    def test_no_change(self):

        # If we don't change anything, settings should be preserved

        with patch('glue.config.settings') as settings:

            settings.FOREGROUND_COLOR = 'red'
            settings.BACKGROUND_COLOR = (0, 0.5, 1)
            settings.DATA_COLOR = (1, 0.5, 0.25)
            settings.DATA_ALPHA = 0.3

            dialog = PreferencesDialog(self.app)
            dialog.show()

            assert dialog.theme == 'Custom'

            dialog.accept()

            assert rgb(settings.FOREGROUND_COLOR) == (1, 0, 0)
            assert rgb(settings.BACKGROUND_COLOR) == (0, 0.5, 1)
            assert rgb(settings.DATA_COLOR) == (1, 0.5, 0.25)
            assert settings.DATA_ALPHA == 0.3
Example #8
0
 def _edit_settings(self):
     self._editor = PreferencesDialog(self)
     self._editor.show()
Example #9
0
class GlueApplication(Application, QtGui.QMainWindow):
    """ The main GUI application for the Qt frontend"""
    def __init__(self, data_collection=None, session=None):

        self.app = get_qapp()

        QtGui.QMainWindow.__init__(self)
        Application.__init__(self,
                             data_collection=data_collection,
                             session=session)

        self.app.setQuitOnLastWindowClosed(True)
        icon = get_icon('app_icon')
        self.app.setWindowIcon(icon)

        # Even though we loaded the plugins in start_glue, we re-load them here
        # in case glue was started directly by initializing this class.
        load_plugins()

        self.setWindowTitle("Glue")
        self.setWindowIcon(icon)
        self.setAttribute(Qt.WA_DeleteOnClose)
        self._actions = {}
        self._terminal = None
        self._setup_ui()
        self.tab_widget.setMovable(True)
        self.tab_widget.setTabsClosable(True)

        # The following is a counter that never goes down, even if tabs are
        # deleted (this is by design, to avoid having two tabs called the
        # same if a tab is removed then a new one added again)
        self._total_tab_count = 0

        lwidget = self._layer_widget
        a = PlotAction(lwidget, self)
        lwidget.ui.layerTree.addAction(a)
        lwidget.bind_selection_to_edit_subset()

        self._tweak_geometry()
        self._create_actions()
        self._create_menu()
        self._connect()
        self.new_tab()
        self._update_plot_dashboard(None)

    def _setup_ui(self):
        self._ui = load_ui('application.ui',
                           None,
                           directory=os.path.dirname(__file__))
        self.setCentralWidget(self._ui)
        self._ui.tabWidget.setTabBar(GlueTabBar())

        lw = LayerTreeWidget()
        lw.set_checkable(False)
        self._vb = QtGui.QVBoxLayout()
        self._vb.setContentsMargins(0, 0, 0, 0)
        self._vb.addWidget(lw)
        self._ui.data_layers.setLayout(self._vb)
        self._layer_widget = lw

        # log window + status light
        self._log = GlueLogger()
        self._log.window().setWindowTitle("Console Log")
        self._log.resize(550, 550)
        self.statusBar().addPermanentWidget(self._log.status_light)
        self.statusBar().setContentsMargins(2, 0, 20, 2)
        self.statusBar().setSizeGripEnabled(False)

    def _tweak_geometry(self):
        """Maximize window"""
        self.setWindowState(Qt.WindowMaximized)
        self._ui.main_splitter.setSizes([100, 800])
        self._ui.data_plot_splitter.setSizes([100, 150, 250])

    @property
    def tab_widget(self):
        return self._ui.tabWidget

    @property
    def tab_bar(self):
        return self._ui.tabWidget.tabBar()

    @property
    def tab_count(self):
        """
        The number of open tabs
        """
        return self._ui.tabWidget.count()

    @property
    def current_tab(self):
        return self._ui.tabWidget.currentWidget()

    def tab(self, index=None):
        if index is None:
            return self.current_tab
        return self._ui.tabWidget.widget(index)

    def new_tab(self):
        """Spawn a new tab page"""
        layout = QtGui.QGridLayout()
        layout.setSpacing(1)
        layout.setContentsMargins(0, 0, 0, 0)
        widget = GlueMdiArea(self)
        widget.setLayout(layout)
        tab = self.tab_widget
        self._total_tab_count += 1
        tab.addTab(widget, str("Tab %i" % self._total_tab_count))
        tab.setCurrentWidget(widget)
        widget.subWindowActivated.connect(self._update_plot_dashboard)

    def close_tab(self, index):
        """ Close a tab window and all associated data viewers """

        # do not delete the last tab
        if self.tab_widget.count() == 1:
            return

        if not os.environ.get('GLUE_TESTING'):
            buttons = QMessageBox.Ok | QMessageBox.Cancel
            dialog = QMessageBox.warning(
                self,
                "Confirm Close", "Are you sure you want to close this tab? "
                "This will close all data viewers in the tab.",
                buttons=buttons,
                defaultButton=QMessageBox.Cancel)
            if not dialog == QMessageBox.Ok:
                return

        w = self.tab_widget.widget(index)

        for window in w.subWindowList():
            widget = window.widget()
            if isinstance(widget, DataViewer):
                widget.close(warn=False)

        w.close()

        self.tab_widget.removeTab(index)

    def add_widget(self,
                   new_widget,
                   label=None,
                   tab=None,
                   hold_position=False):
        """
        Add a widget to one of the tabs.

        Returns the window that this widget is wrapped in.

        :param new_widget: new QtGui.QWidget to add

        :param label: label for the new window. Optional
        :type label: str

        :param tab: Tab to add to. Optional (default: current tab)
        :type tab: int

        :param hold_position: If True, then override Qt's default
                              placement and retain the original position
                              of new_widget
        :type hold_position: bool
        """
        page = self.tab(tab)
        pos = getattr(new_widget, 'position', None)
        sub = new_widget.mdi_wrap()

        sub.closed.connect(self._clear_dashboard)

        if label:
            sub.setWindowTitle(label)
        page.addSubWindow(sub)
        page.setActiveSubWindow(sub)
        if hold_position and pos is not None:
            new_widget.move(pos[0], pos[1])
        return sub

    def _edit_settings(self):
        self._editor = PreferencesDialog(self)
        self._editor.show()

    def gather_current_tab(self):
        """Arrange windows in current tab via tiling"""
        self.current_tab.tileSubWindows()

    def _get_plot_dashboards(self, sub_window):

        if not isinstance(sub_window, GlueMdiSubWindow):
            return QtGui.QWidget(), QtGui.QWidget(), ""

        widget = sub_window.widget()
        if not isinstance(widget, DataViewer):
            return QtGui.QWidget(), QtGui.QWidget(), ""

        layer_view = widget.layer_view()
        options_widget = widget.options_widget()

        return layer_view, options_widget, str(widget)

    def _clear_dashboard(self):

        for widget, title in [(self._ui.plot_layers, "Plot Layers"),
                              (self._ui.plot_options, "Plot Options")]:
            layout = widget.layout()
            if layout is None:
                layout = QtGui.QVBoxLayout()
                layout.setContentsMargins(4, 4, 4, 4)
                widget.setLayout(layout)
            while layout.count():
                layout.takeAt(0).widget().hide()
            widget.setTitle(title)

    def _update_plot_dashboard(self, sub_window):
        self._clear_dashboard()

        if sub_window is None:
            return

        layer_view, options_widget, title = self._get_plot_dashboards(
            sub_window)

        layout = self._ui.plot_layers.layout()
        layout.addWidget(layer_view)

        layout = self._ui.plot_options.layout()
        layout.addWidget(options_widget)

        layer_view.show()
        options_widget.show()

        if title:
            self._ui.plot_options.setTitle("Plot Options - %s" % title)
            self._ui.plot_layers.setTitle("Plot Layers - %s" % title)
        else:
            self._ui.plot_options.setTitle("Plot Options")
            self._ui.plot_layers.setTitle("Plot Layers")

        self._update_focus_decoration()

    def _update_focus_decoration(self):
        mdi_area = self.current_tab
        active = mdi_area.activeSubWindow()

        for win in mdi_area.subWindowList():
            widget = win.widget()
            if isinstance(widget, DataViewer):
                widget.set_focus(win is active)

    def _connect(self):
        self.setAcceptDrops(True)
        self._layer_widget.setup(self._data)

        self.tab_widget.tabCloseRequested.connect(self.close_tab)

    def _create_menu(self):
        mbar = self.menuBar()
        menu = QtGui.QMenu(mbar)
        menu.setTitle("&File")

        menu.addAction(self._actions['data_new'])
        if 'data_importers' in self._actions:
            submenu = menu.addMenu("I&mport data")
            for a in self._actions['data_importers']:
                submenu.addAction(a)
        # menu.addAction(self._actions['data_save'])  # XXX add this
        menu.addAction(self._actions['session_reset'])
        menu.addAction(self._actions['session_restore'])
        menu.addAction(self._actions['session_save'])
        if 'session_export' in self._actions:
            submenu = menu.addMenu("E&xport")
            for a in self._actions['session_export']:
                submenu.addAction(a)
        menu.addSeparator()
        menu.addAction("Edit &Settings", self._edit_settings)
        menu.addAction("&Quit", QtGui.qApp.quit)
        mbar.addMenu(menu)

        menu = QtGui.QMenu(mbar)
        menu.setTitle("&Edit ")
        menu.addAction(self._actions['undo'])
        menu.addAction(self._actions['redo'])
        mbar.addMenu(menu)

        menu = QtGui.QMenu(mbar)
        menu.setTitle("&View ")

        a = QtGui.QAction("&Console Log", menu)
        a.triggered.connect(self._log._show)
        menu.addAction(a)
        mbar.addMenu(menu)

        menu = QtGui.QMenu(mbar)
        menu.setTitle("&Canvas")
        menu.addAction(self._actions['tab_new'])
        menu.addAction(self._actions['viewer_new'])
        menu.addSeparator()
        menu.addAction(self._actions['gather'])
        menu.addAction(self._actions['tab_rename'])
        mbar.addMenu(menu)

        menu = QtGui.QMenu(mbar)
        menu.setTitle("Data &Manager")
        menu.addActions(self._layer_widget.actions())

        mbar.addMenu(menu)

        menu = QtGui.QMenu(mbar)
        menu.setTitle("&Toolbars")
        tbar = EditSubsetModeToolBar()
        self._mode_toolbar = tbar
        self.addToolBar(tbar)
        tbar.hide()
        a = QtGui.QAction("Selection Mode &Toolbar", menu)
        a.setCheckable(True)
        a.toggled.connect(tbar.setVisible)
        try:
            tbar.visibilityChanged.connect(a.setChecked)
        except AttributeError:  # Qt < 4.7. QtCore.Signal not supported
            pass

        menu.addAction(a)
        menu.addActions(tbar.actions())
        mbar.addMenu(menu)

        menu = QtGui.QMenu(mbar)
        menu.setTitle("&Plugins")
        menu.addAction(self._actions['plugin_manager'])
        menu.addSeparator()

        if 'plugins' in self._actions:
            for plugin in self._actions['plugins']:
                menu.addAction(plugin)

        mbar.addMenu(menu)

        # trigger inclusion of Mac Native "Help" tool
        menu = mbar.addMenu("&Help")

        a = QtGui.QAction("&Online Documentation", menu)
        a.triggered.connect(nonpartial(webbrowser.open, DOCS_URL))
        menu.addAction(a)

        a = QtGui.QAction("Send &Feedback", menu)
        a.triggered.connect(nonpartial(submit_feedback))
        menu.addAction(a)

        menu.addSeparator()
        menu.addAction("Version information", show_glue_info)

    def _choose_load_data(self, data_importer=None):
        if data_importer is None:
            self.add_datasets(self.data_collection, data_wizard())
        else:
            data = data_importer()
            if not isinstance(data, list):
                raise TypeError(
                    "Data loader should return list of Data objects")
            for item in data:
                if not isinstance(item, Data):
                    raise TypeError(
                        "Data loader should return list of Data objects")
            self.add_datasets(self.data_collection, data)

    def _create_actions(self):
        """ Create and connect actions, store in _actions dict """
        self._actions = {}

        a = action("&New Data Viewer",
                   self,
                   tip="Open a new visualization window in the current tab",
                   shortcut=QtGui.QKeySequence.New)
        a.triggered.connect(nonpartial(self.choose_new_data_viewer))
        self._actions['viewer_new'] = a

        a = action('New &Tab',
                   self,
                   shortcut=QtGui.QKeySequence.AddTab,
                   tip='Add a new tab')
        a.triggered.connect(nonpartial(self.new_tab))
        self._actions['tab_new'] = a

        a = action('&Rename Tab',
                   self,
                   shortcut="Ctrl+R",
                   tip='Set a new label for the current tab')
        a.triggered.connect(nonpartial(self.tab_bar.rename_tab))
        self._actions['tab_rename'] = a

        a = action('&Gather Windows',
                   self,
                   tip='Gather plot windows side-by-side',
                   shortcut='Ctrl+G')
        a.triggered.connect(nonpartial(self.gather_current_tab))
        self._actions['gather'] = a

        a = action('&Save Session', self, tip='Save the current session')
        a.triggered.connect(nonpartial(self._choose_save_session))
        self._actions['session_save'] = a

        # Add file loader as first item in File menu for convenience. We then
        # also add it again below in the Import menu for consistency.
        a = action("&Open Data Set",
                   self,
                   tip="Open a new data set",
                   shortcut=QtGui.QKeySequence.Open)
        a.triggered.connect(nonpartial(self._choose_load_data, data_wizard))
        self._actions['data_new'] = a

        # We now populate the "Import data" menu
        from glue.config import importer

        acts = []

        # Add default file loader (later we can add this to the registry)
        a = action("Import from file", self, tip="Import from file")
        a.triggered.connect(nonpartial(self._choose_load_data, data_wizard))
        acts.append(a)

        for i in importer:
            label, data_importer = i
            a = action(label, self, tip=label)
            a.triggered.connect(
                nonpartial(self._choose_load_data, data_importer))
            acts.append(a)

        self._actions['data_importers'] = acts

        from glue.config import exporters
        if len(exporters) > 0:
            acts = []
            for e in exporters:
                label, saver, checker, mode = e
                a = action(label,
                           self,
                           tip='Export the current session to %s format' %
                           label)
                a.triggered.connect(
                    nonpartial(self._choose_export_session, saver, checker,
                               mode))
                acts.append(a)

            self._actions['session_export'] = acts

        a = action('Open S&ession', self, tip='Restore a saved session')
        a.triggered.connect(nonpartial(self._restore_session))
        self._actions['session_restore'] = a

        a = action('Reset S&ession', self, tip='Reset session to clean state')
        a.triggered.connect(nonpartial(self._reset_session))
        self._actions['session_reset'] = a

        a = action("Undo",
                   self,
                   tip='Undo last action',
                   shortcut=QtGui.QKeySequence.Undo)
        a.triggered.connect(nonpartial(self.undo))
        a.setEnabled(False)
        self._actions['undo'] = a

        a = action("Redo",
                   self,
                   tip='Redo last action',
                   shortcut=QtGui.QKeySequence.Redo)
        a.triggered.connect(nonpartial(self.redo))
        a.setEnabled(False)
        self._actions['redo'] = a

        # Create actions for menubar plugins
        from glue.config import menubar_plugin
        acts = []
        for label, function in menubar_plugin:
            a = action(label, self, tip=label)
            a.triggered.connect(
                nonpartial(function, self.session, self.data_collection))
            acts.append(a)
        self._actions['plugins'] = acts

        a = action('&Plugin Manager', self, tip='Open plugin manager')
        a.triggered.connect(nonpartial(self.plugin_manager))
        self._actions['plugin_manager'] = a

    def choose_new_data_viewer(self, data=None):
        """ Create a new visualization window in the current tab
        """

        from glue.config import qt_client

        if data and data.ndim == 1 and ScatterWidget in qt_client.members:
            default = ScatterWidget
        elif data and data.ndim > 1 and ImageWidget in qt_client.members:
            default = ImageWidget
        else:
            default = None

        client = pick_class(list(qt_client.members),
                            title='Data Viewer',
                            label="Choose a new data viewer",
                            default=default,
                            sort=True)

        cmd = command.NewDataViewer(viewer=client, data=data)
        return self.do(cmd)

    new_data_viewer = defer_draw(Application.new_data_viewer)

    @set_cursor(Qt.WaitCursor)
    def _choose_save_session(self):
        """ Save the data collection and hub to file.

        Can be restored via restore_session
        """

        # include file filter twice, so it shows up in Dialog
        outfile, file_filter = QtGui.QFileDialog.getSaveFileName(
            self,
            filter="Glue Session (*.glu);; Glue Session including data (*.glu)"
        )

        # This indicates that the user cancelled
        if not outfile:
            return

        # Add extension if not specified
        if not '.' in outfile:
            outfile += '.glu'

        self.save_session(outfile,
                          include_data="including data" in file_filter)

    @messagebox_on_error("Failed to export session")
    def _choose_export_session(self, saver, checker, outmode):
        checker(self)
        if outmode in ['file', 'directory']:
            outfile, file_filter = QtGui.QFileDialog.getSaveFileName(self)
            if not outfile:
                return
            return saver(self, outfile)
        else:
            assert outmode == 'label'
            label, ok = QtGui.QInputDialog.getText(self, 'Choose a label:',
                                                   'Choose a label:')
            if not ok:
                return
            return saver(self, label)

    @messagebox_on_error("Failed to restore session")
    @set_cursor(Qt.WaitCursor)
    def _restore_session(self, show=True):
        """ Load a previously-saved state, and restart the session """
        fltr = "Glue sessions (*.glu)"
        file_name, file_filter = QtGui.QFileDialog.getOpenFileName(self,
                                                                   filter=fltr)
        if not file_name:
            return

        ga = self.restore_session(file_name)
        self.close()
        return ga

    def _reset_session(self, show=True):
        """
        Reset session to clean state.
        """

        if not os.environ.get('GLUE_TESTING'):
            buttons = QMessageBox.Ok | QMessageBox.Cancel
            dialog = QMessageBox.warning(
                self,
                "Confirm Close", "Are you sure you want to reset the session? "
                "This will close all datasets, subsets, and data viewers",
                buttons=buttons,
                defaultButton=QMessageBox.Cancel)
            if not dialog == QMessageBox.Ok:
                return

        ga = GlueApplication()
        ga.show()
        self.close()

        return ga

    @staticmethod
    def restore_session(path, show=True):
        """
        Reload a previously-saved session

        Parameters
        ----------
        path : str
            Path to the file to load
        show : bool, optional
            If True (the default), immediately show the widget

        Returns
        -------
        app : :class:`glue.app.qt.application.GlueApplication`
            The loaded application
        """
        ga = Application.restore_session(path)
        if show:
            ga.show()
        return ga

    def has_terminal(self):
        """
        Returns True if the IPython terminal is present.
        """
        self._create_terminal()  # ensure terminal is setup
        return self._terminal is not None

    def _create_terminal(self):
        if self._terminal is not None:  # already set up
            return

        if hasattr(self, '_terminal_exception'):  # already failed to set up
            return

        self._terminal_button = QtGui.QToolButton(self._ui)
        self._terminal_button.setToolTip("Toggle IPython Prompt")
        i = get_icon('IPythonConsole')
        self._terminal_button.setIcon(i)
        self._terminal_button.setIconSize(QtCore.QSize(25, 25))

        self._layer_widget.ui.button_row.addWidget(self._terminal_button)

        try:
            from glue.app.qt.terminal import glue_terminal
            widget = glue_terminal(data_collection=self._data,
                                   dc=self._data,
                                   hub=self._hub,
                                   session=self.session,
                                   application=self,
                                   **vars(env))
            self._terminal_button.clicked.connect(self._toggle_terminal)
        except Exception as e:  # pylint: disable=W0703
            import traceback
            self._terminal_exception = traceback.format_exc()
            self._setup_terminal_error_dialog(e)
            return

        self._terminal = self.add_widget(widget, label='IPython')
        self._hide_terminal()

    def _setup_terminal_error_dialog(self, exception):
        """ Reassign the terminal toggle button to show dialog on error"""
        title = "Terminal unavailable"
        msg = ("Glue encountered an error trying to start the Terminal"
               "\nReason:\n%s" % exception)

        def show_msg():
            mb = QMessageBox(QMessageBox.Critical, title, msg)
            mb.setDetailedText(self._terminal_exception)
            mb.exec_()

        self._terminal_button.clicked.connect(show_msg)

    def _toggle_terminal(self):
        if self._terminal.isVisible():
            self._hide_terminal()
            if self._terminal.isVisible():
                warnings.warn(
                    "An unexpected error occurred while trying to hide the terminal"
                )
        else:
            self._show_terminal()
            if not self._terminal.isVisible():
                warnings.warn(
                    "An unexpected error occurred while trying to show the terminal"
                )

    def _hide_terminal(self):
        self._terminal.hide()

    def _show_terminal(self):
        self._terminal.show()
        self._terminal.widget().show()

    def start(self, size=None, position=None):
        """
        Show the GUI and start the application.

        Parameters
        ----------
        size : (int, int) Optional
            The default width/height of the application.
            If not provided, uses the full screen
        position : (int, int) Optional
            The default position of the application
        """
        self._create_terminal()
        self.show()
        if size is not None:
            self.resize(*size)
        if position is not None:
            self.move(*position)

        self.raise_()  # bring window to front
        # at some point during all this, the MPL backend
        # switches. This call restores things, so
        # figures are still inlined in the notebook.
        # XXX find out a better place for this
        _fix_ipython_pylab()
        return self.app.exec_()

    exec_ = start

    def keyPressEvent(self, event):
        """Hold down modifier keys to temporarily set edit mode"""
        mod = event.modifiers()
        if mod == Qt.ShiftModifier:
            self._mode_toolbar.set_mode('or')

    def keyReleaseEvent(self, event):
        """Unset any temporary edit mode"""
        self._mode_toolbar.unset_mode()

    def dragEnterEvent(self, event):
        if event.mimeData().hasUrls():
            event.accept()
        else:
            event.ignore()

    def dropEvent(self, event):
        urls = event.mimeData().urls()
        for url in urls:
            self.load_data(url.path())
        event.accept()

    def report_error(self, message, detail):
        """
        Display an error in a modal

        :param message: A short description of the error
        :type message: str
        :param detail: A longer description
        :type detail: str
        """
        qmb = QMessageBox(QMessageBox.Critical, "Error", message)
        qmb.setDetailedText(detail)
        qmb.resize(400, qmb.size().height())
        qmb.exec_()

    def plugin_manager(self):
        from glue.main import _installed_plugins
        pm = QtPluginManager(installed=_installed_plugins)
        pm.ui.exec_()

    def _update_undo_redo_enabled(self):
        undo, redo = self._cmds.can_undo_redo()
        self._actions['undo'].setEnabled(undo)
        self._actions['redo'].setEnabled(redo)
        self._actions['undo'].setText('Undo ' + self._cmds.undo_label)
        self._actions['redo'].setText('Redo ' + self._cmds.redo_label)

    @property
    def viewers(self):
        """
        A list of lists of open Data Viewers.

        Each inner list contains the viewers open on a particular tab.
        """
        result = []
        for t in range(self.tab_count):
            tab = self.tab(t)
            item = []
            for subwindow in tab.subWindowList():
                widget = subwindow.widget()
                if isinstance(widget, DataViewer):
                    item.append(widget)
            result.append(tuple(item))
        return tuple(result)

    @property
    def tab_names(self):
        """
        The name of each tab

        A list of strings
        """
        return [self.tab_bar.tabText(i) for i in range(self.tab_count)]

    @staticmethod
    def _choose_merge(data, others):

        w = load_ui('merge.ui', None, directory=os.path.dirname(__file__))
        w.button_yes.clicked.connect(w.accept)
        w.button_no.clicked.connect(w.reject)
        w.show()
        w.raise_()

        # Add the main dataset to the list. Some of the 'others' may also be
        # new ones, so it doesn't really make sense to distinguish between
        # the two here. The main point is that some datasets, including at
        # least one new one, have a common shape.
        others.append(data)
        others.sort(key=lambda x: x.label)

        label = others[0].label
        w.merged_label.setText(label)

        entries = [QtGui.QListWidgetItem(other.label) for other in others]
        for e in entries:
            e.setCheckState(Qt.Checked)

        for d, item in zip(others, entries):
            w.choices.addItem(item)

        if not w.exec_():
            return None, None

        result = [
            layer for layer, entry in zip(others, entries)
            if entry.checkState() == Qt.Checked
        ]

        if result:
            return result, str(w.merged_label.text())

        return None, None
Example #10
0
    def test_themes(self):

        # Check that themes work

        with patch('glue.config.settings') as settings:

            settings.FOREGROUND_COLOR = 'red'
            settings.BACKGROUND_COLOR = (0, 0.5, 1)
            settings.DATA_COLOR = (1, 0.5, 0.25)
            settings.DATA_ALPHA = 0.3

            dialog = PreferencesDialog(self.app)
            dialog.show()
            dialog.theme = 'White on Black'
            dialog.accept()

            assert rgb(settings.FOREGROUND_COLOR) == (1, 1, 1)
            assert rgb(settings.BACKGROUND_COLOR) == (0, 0, 0)
            assert rgb(settings.DATA_COLOR) == (0.75, 0.75, 0.75)
            assert settings.DATA_ALPHA == 0.8

            dialog = PreferencesDialog(self.app)
            dialog.show()
            dialog.theme = 'Black on White'
            dialog.accept()

            assert rgb(settings.FOREGROUND_COLOR) == (0, 0, 0)
            assert rgb(settings.BACKGROUND_COLOR) == (1, 1, 1)
            assert rgb(settings.DATA_COLOR) == (0.35, 0.35, 0.35)
            assert settings.DATA_ALPHA == 0.8
Example #11
0
    def test_theme_autodetect(self):

        # If we don't change anything, settings should be preserved

        with patch('glue.config.settings') as settings:

            settings.FOREGROUND_COLOR = 'white'
            settings.BACKGROUND_COLOR = 'black'
            settings.DATA_COLOR = '0.75'
            settings.DATA_ALPHA = 0.8

            dialog = PreferencesDialog(self.app)
            dialog.show()
            assert dialog.theme == 'White on Black'
            dialog.accept()

            settings.FOREGROUND_COLOR = 'black'
            settings.BACKGROUND_COLOR = 'white'
            settings.DATA_COLOR = '0.35'
            settings.DATA_ALPHA = 0.8

            dialog = PreferencesDialog(self.app)
            dialog.show()
            assert dialog.theme == 'Black on White'
            dialog.accept()
Example #12
0
def test_foreground_background_settings():

    d_1d = Data(x=np.random.random(100),
                y=np.random.random(100),
                label='Data 1d')
    d_2d = Data(x=np.random.random((100, 100)),
                y=np.random.random((100, 100)),
                label='Data 2d')

    dc = DataCollection([d_1d, d_2d])

    app = GlueApplication(dc)

    # Make sure that settings change existing viewers, so we create a bunch of
    # viewers here.

    scatter1 = app.new_data_viewer(ScatterViewer)
    scatter1.add_data(d_1d)

    image1 = app.new_data_viewer(ImageViewer)
    image1.add_data(d_2d)

    histogram1 = app.new_data_viewer(HistogramViewer)
    histogram1.add_data(d_1d)

    dendrogram1 = app.new_data_viewer(DendroWidget)

    example_custom = _generate_custom_viewer()

    custom1 = app.new_data_viewer(example_custom)

    RED = (1, 0, 0, 0.5)
    GREEN = (0, 1, 0, 0.6)

    app.show()

    with patch('glue.config.settings') as settings:

        settings.FOREGROUND_COLOR = 'black'
        settings.BACKGROUND_COLOR = 'white'
        settings.DATA_COLOR = '0.5'
        settings.DATA_ALPHA = 0.5

        dialog = PreferencesDialog(app)
        dialog.show()
        dialog.background = RED
        dialog.foreground = GREEN
        dialog.accept()

        assert_axes_background(scatter1.axes, RED)
        assert_axes_background(image1.axes, RED)
        assert_axes_background(histogram1.axes, RED)
        assert_axes_background(dendrogram1.axes, RED)
        assert_axes_background(custom1.axes, RED)

        assert_axes_foreground(scatter1.axes, GREEN)
        assert_axes_foreground(image1.axes, GREEN)
        assert_axes_foreground(histogram1.axes, GREEN)
        assert_axes_foreground(dendrogram1.axes, GREEN)
        assert_axes_foreground(custom1.axes, GREEN)

        # Now make sure that new viewers also inherit these settings

        scatter2 = app.new_data_viewer(ScatterViewer)
        scatter2.add_data(d_1d)

        image2 = app.new_data_viewer(ImageViewer)
        image2.add_data(d_2d)

        histogram2 = app.new_data_viewer(HistogramViewer)
        histogram2.add_data(d_1d)

        dendrogram2 = app.new_data_viewer(DendroWidget)
        custom2 = app.new_data_viewer(example_custom)

        assert_axes_background(scatter2.axes, RED)
        assert_axes_background(image2.axes, RED)
        assert_axes_background(histogram2.axes, RED)
        assert_axes_background(dendrogram2.axes, RED)
        assert_axes_background(custom2.axes, RED)

        assert_axes_foreground(scatter2.axes, GREEN)
        assert_axes_foreground(image2.axes, GREEN)
        assert_axes_foreground(histogram2.axes, GREEN)
        assert_axes_foreground(dendrogram2.axes, GREEN)
        assert_axes_foreground(custom2.axes, GREEN)
Example #13
0
 def _edit_settings(self, *args):
     self._editor = PreferencesDialog(self, parent=self)
     self._editor.show()
Example #14
0
    def test_custom_changes(self):

        # Check that themes work

        with patch('glue.config.settings') as settings:

            settings.FOREGROUND_COLOR = 'red'
            settings.BACKGROUND_COLOR = (0, 0.5, 1)
            settings.DATA_COLOR = (1, 0.5, 0.25)
            settings.DATA_ALPHA = 0.3

            dialog = PreferencesDialog(self.app)
            dialog.show()
            dialog.foreground = (0, 1, 1)
            dialog.accept()

            assert rgb(settings.FOREGROUND_COLOR) == (0, 1, 1)
            assert rgb(settings.BACKGROUND_COLOR) == (0, 0.5, 1)
            assert rgb(settings.DATA_COLOR) == (1, 0.5, 0.25)
            assert settings.DATA_ALPHA == 0.3

            dialog = PreferencesDialog(self.app)
            dialog.show()
            dialog.background = (1, 0, 1)
            dialog.accept()

            assert rgb(settings.FOREGROUND_COLOR) == (0, 1, 1)
            assert rgb(settings.BACKGROUND_COLOR) == (1, 0, 1)
            assert rgb(settings.DATA_COLOR) == (1, 0.5, 0.25)
            assert settings.DATA_ALPHA == 0.3

            dialog = PreferencesDialog(self.app)
            dialog.show()
            dialog.data_color = (1, 1, 0.5)
            dialog.accept()

            assert rgb(settings.FOREGROUND_COLOR) == (0, 1, 1)
            assert rgb(settings.BACKGROUND_COLOR) == (1, 0, 1)
            assert rgb(settings.DATA_COLOR) == (1, 1, 0.5)
            assert settings.DATA_ALPHA == 0.3

            dialog = PreferencesDialog(self.app)
            dialog.show()
            dialog.data_alpha = 0.4
            dialog.accept()

            assert rgb(settings.FOREGROUND_COLOR) == (0, 1, 1)
            assert rgb(settings.BACKGROUND_COLOR) == (1, 0, 1)
            assert rgb(settings.DATA_COLOR) == (1, 1, 0.5)
            assert settings.DATA_ALPHA == 0.4
Example #15
0
class GlueApplication(Application, QtWidgets.QMainWindow):
    """ The main GUI application for the Qt frontend"""
    def __init__(self, data_collection=None, session=None):

        # At this point we need to check if a Qt application already exists -
        # this happens for example if using the %gui qt/qt5 mode in Jupyter. We
        # should keep a reference to the original icon so that we can restore it
        # later
        self._original_app = QtWidgets.QApplication.instance()
        if self._original_app is not None:
            self._original_icon = self._original_app.windowIcon()

        self._export_helper = ExportHelper(self)
        self._import_helper = ImportHelper(self)

        # Now we can get the application instance, which involves setting it
        # up if it doesn't already exist.
        self.app = get_qapp()

        QtWidgets.QMainWindow.__init__(self)
        Application.__init__(self,
                             data_collection=data_collection,
                             session=session)

        # Pull in any keybindings from an external file
        self.keybindings = keyboard_shortcut

        icon = get_icon('app_icon')
        self.app.setWindowIcon(icon)

        # Even though we loaded the plugins in start_glue, we re-load them here
        # in case glue was started directly by initializing this class.
        load_plugins(require_qt_plugins=True)

        self.setWindowTitle("Glue")
        self.setWindowIcon(icon)
        self.setAttribute(Qt.WA_DeleteOnClose)
        self._actions = {}
        self._terminal = None
        self._setup_ui()
        self.tab_widget.setMovable(True)
        self.tab_widget.setTabsClosable(True)

        # The following is a counter that never goes down, even if tabs are
        # deleted (this is by design, to avoid having two tabs called the
        # same if a tab is removed then a new one added again)
        self._total_tab_count = 0

        lwidget = self._layer_widget
        a = PlotAction(lwidget, self)
        lwidget.ui.layerTree.addAction(a)

        self._tweak_geometry()
        self._create_actions()
        self._create_menu()
        self._connect()
        self.new_tab()
        self._update_viewer_in_focus()

    def _update_viewer_in_focus(self, *args):

        if not hasattr(self, '_viewer_in_focus'):
            self._viewer_in_focus = None

        mdi_area = self.current_tab
        active = mdi_area.activeSubWindow()

        # Disable any active tool in the viewer that was previously in focus.
        # Note that we want to do this even if active is None, which means that
        # the user may have switched application.
        if (self._viewer_in_focus is not None and
            (active is None or active.widget() is not self._viewer_in_focus)):
            try:
                self._viewer_in_focus.toolbar.active_tool = None
            except AttributeError:
                pass  # not all viewers have toolbars

        if active is None:
            first_viewer = None
            for win in mdi_area.subWindowList():
                if self._viewer_in_focus is win.widget():
                    break
                elif isinstance(win.widget(), DataViewer):
                    first_viewer = win.widget()
            else:
                self._viewer_in_focus = first_viewer
                self._update_focus_decoration()
                self._update_plot_dashboard()
        else:
            self._viewer_in_focus = active.widget()
            self._update_focus_decoration()
            self._update_plot_dashboard()

    def run_startup_action(self, name):
        if name in startup_action.members:
            startup_action.members[name](self.session, self.data_collection)
        else:
            raise Exception("Unknown startup action: {0}".format(name))

    def _setup_ui(self):
        self._ui = load_ui('application.ui',
                           None,
                           directory=os.path.dirname(__file__))
        self.setCentralWidget(self._ui)
        self._ui.tabWidget.setTabBar(GlueTabBar())

        lw = LayerTreeWidget(session=self._session)
        lw.set_checkable(False)
        self._vb = QtWidgets.QVBoxLayout()
        self._vb.setContentsMargins(0, 0, 0, 0)
        self._vb.addWidget(lw)
        self._ui.data_layers.setLayout(self._vb)
        self._layer_widget = lw

        # Data toolbar

        self._data_toolbar = QtWidgets.QToolBar("Session and Data toolbar")

        self._data_toolbar.setIconSize(QtCore.QSize(16, 16))

        self._button_open_session = QtWidgets.QToolButton()
        self._button_open_session.setText("Open Session")
        self._button_open_session.setIcon(get_icon('glue_open'))
        self._button_open_session.setToolButtonStyle(
            Qt.ToolButtonTextBesideIcon)
        self._button_open_session.clicked.connect(self._restore_session)

        self._data_toolbar.addWidget(self._button_open_session)

        self._button_save_session = QtWidgets.QToolButton()
        self._button_save_session.setText("Export Session")
        self._button_save_session.setIcon(get_icon('glue_filesave'))
        self._button_save_session.setToolButtonStyle(
            Qt.ToolButtonTextBesideIcon)
        self._button_save_session.clicked.connect(self._choose_save_session)

        self._data_toolbar.addWidget(self._button_save_session)
        self._button_open_data = QtWidgets.QToolButton()
        self._button_open_data.setText("Import Data")
        self._button_open_data.setIcon(get_icon('glue_open'))
        self._button_open_data.setToolButtonStyle(Qt.ToolButtonTextBesideIcon)
        self._button_open_data.clicked.connect(
            self._import_helper._choose_load_data_wizard)

        self._data_toolbar.addWidget(self._button_open_data)

        self._button_save_data = QtWidgets.QToolButton()
        self._button_save_data.setText("Export Data/Subsets")
        self._button_save_data.setIcon(get_icon('glue_filesave'))
        self._button_save_data.setToolButtonStyle(Qt.ToolButtonTextBesideIcon)
        self._button_save_data.clicked.connect(self._choose_save_data)

        self._data_toolbar.addWidget(self._button_save_data)

        self._button_link_data = QtWidgets.QToolButton()
        self._button_link_data.setText("Link Data")
        self._button_link_data.setIcon(get_icon('glue_link'))
        self._button_link_data.setToolButtonStyle(Qt.ToolButtonTextBesideIcon)
        self._button_link_data.clicked.connect(self._set_up_links)

        self._data_toolbar.addWidget(self._button_link_data)

        self._button_edit_components = QtWidgets.QToolButton()
        self._button_edit_components.setText("Arithmetic attributes")
        self._button_edit_components.setIcon(get_icon('arithmetic'))
        self._button_edit_components.setToolButtonStyle(
            Qt.ToolButtonTextBesideIcon)
        self._button_edit_components.clicked.connect(self._artihmetic_dialog)

        self._data_toolbar.addWidget(self._button_edit_components)

        self.addToolBar(self._data_toolbar)

        self._on_data_collection_change()

        # Selection mode toolbar

        tbar = EditSubsetModeToolBar(parent=self)
        self._mode_toolbar = tbar

        self.addToolBar(self._mode_toolbar)

        # Error console toolbar

        self._console_toolbar = QtWidgets.QToolBar('Advanced toolbar')

        self._console_toolbar.setIconSize(QtCore.QSize(14, 14))

        spacer = QtWidgets.QWidget()
        spacer.setSizePolicy(QtWidgets.QSizePolicy.Expanding,
                             QtWidgets.QSizePolicy.Preferred)

        self._console_toolbar.addWidget(spacer)

        self._button_ipython = QtWidgets.QToolButton()
        self._button_ipython.setCheckable(True)
        self._button_ipython.setText("Terminal")
        self._button_ipython.setIcon(get_icon('IPythonConsole'))
        self._button_ipython.setToolButtonStyle(Qt.ToolButtonTextBesideIcon)
        self._button_ipython.clicked.connect(self._toggle_terminal)

        self._console_toolbar.addWidget(self._button_ipython)

        self._button_preferences = QtWidgets.QToolButton()
        self._button_preferences.setText("Preferences")
        self._button_preferences.setIcon(get_icon('glue_settings'))
        self._button_preferences.setToolButtonStyle(
            Qt.ToolButtonTextBesideIcon)
        self._button_preferences.clicked.connect(self._edit_settings)

        self._console_toolbar.addWidget(self._button_preferences)

        self._button_console = QtWidgets.QToolButton()
        self._button_console.setText("Error Console")
        self._button_console.setToolButtonStyle(Qt.ToolButtonTextOnly)

        self._console_toolbar.addWidget(self._button_console)

        self.addToolBar(self._console_toolbar)

        self._log = GlueLogger(button_console=self._button_console)
        self._log.window().setWindowTitle("Console Log")
        self._log.resize(550, 550)
        self._log.hide()

        self._hub.subscribe(self,
                            DataCollectionMessage,
                            handler=self._on_data_collection_change)
        self._hub.subscribe(self,
                            SettingsChangeMessage,
                            handler=self._on_ui_settings_change)

    def _artihmetic_dialog(self, *event):
        dialog = ArithmeticEditorWidget(self.data_collection)
        dialog.exec_()

    def _on_data_collection_change(self, *event):
        self._button_save_data.setEnabled(len(self.data_collection) > 0)
        self._button_link_data.setEnabled(len(self.data_collection) > 1)
        self._button_edit_components.setEnabled(len(self.data_collection) > 0)

    def _on_ui_settings_change(self, *event):
        update_global_font_size()

    def keyPressEvent(self, event):
        if self.current_tab.activeSubWindow(
        ) and self.current_tab.activeSubWindow().widget():
            active_window = self.current_tab.activeSubWindow().widget()
        else:
            active_window = None

        # keybindings is a data structure in the form of dict(dict)
        # which uses the DataViewer as the first key, the key pressed
        # as the second key, and the function associated with those two
        # as the value.
        if type(active_window) in self.keybindings:
            for k, func in self.keybindings.members[type(
                    active_window)].items():
                if event.key() == k:
                    func(self.session)
                    return

        # If key does not correspond with viewers, it might correspond
        # with the global application, thus, None
        if None in self.keybindings:
            for k, func in self.keybindings.members[None].items():
                if event.key() == k:
                    func(self.session)
                    return

        return super(GlueApplication, self).keyPressEvent(event)

    def _set_up_links(self, event):
        LinkEditor.update_links(self.data_collection)

    def _tweak_geometry(self):
        """Maximize window by default."""
        self._ui.main_splitter.setStretchFactor(0, 1)
        self._ui.main_splitter.setStretchFactor(1, 9)
        self._ui.data_plot_splitter.setStretchFactor(0, 1)
        self._ui.data_plot_splitter.setStretchFactor(1, 2)
        self._ui.data_plot_splitter.setStretchFactor(2, 1)

    @property
    def tab_widget(self):
        return self._ui.tabWidget

    @property
    def tab_bar(self):
        return self._ui.tabWidget.tabBar()

    @property
    def tab_count(self):
        """
        The number of open tabs
        """
        return self._ui.tabWidget.count()

    @property
    def current_tab(self):
        return self._ui.tabWidget.currentWidget()

    def get_tab_index(self, widget):
        for idx in range(self.tab_count):
            if self.tab(idx) == widget:
                return idx
        raise Exception("Tab not found")

    def tab(self, index=None):
        if index is None:
            return self.current_tab
        return self._ui.tabWidget.widget(index)

    def new_tab(self, *args):
        """Spawn a new tab page"""
        layout = QtWidgets.QGridLayout()
        layout.setSpacing(1)
        layout.setContentsMargins(0, 0, 0, 0)
        widget = GlueMdiArea(self)
        widget.setLayout(layout)
        tab = self.tab_widget
        self._total_tab_count += 1
        tab.addTab(widget, str("Tab %i" % self._total_tab_count))
        tab.setCurrentWidget(widget)
        widget.subWindowActivated.connect(self._update_viewer_in_focus)

    def close_tab(self, index, warn=True):
        """ Close a tab window and all associated data viewers """

        # do not delete the last tab
        if self.tab_widget.count() == 1:
            return

        w = self.tab_widget.widget(index)

        if len(w.subWindowList()) > 0:

            if warn and not os.environ.get('GLUE_TESTING'):
                buttons = QtWidgets.QMessageBox.Ok | QtWidgets.QMessageBox.Cancel
                dialog = QtWidgets.QMessageBox.warning(
                    self,
                    "Confirm Close",
                    "Are you sure you want to close this tab? "
                    "This will close all data viewers in the tab.",
                    buttons=buttons,
                    defaultButton=QtWidgets.QMessageBox.Cancel)
                if not dialog == QtWidgets.QMessageBox.Ok:
                    return

            for window in w.subWindowList():
                widget = window.widget()
                if isinstance(widget, DataViewer):
                    widget.close(warn=False)

        w.close()

        self.tab_widget.removeTab(index)

    def add_widget(self,
                   new_widget,
                   label=None,
                   tab=None,
                   hold_position=False):
        """
        Add a widget to one of the tabs.

        Returns the window that this widget is wrapped in.

        :param new_widget: new QtWidgets.QWidget to add

        :param label: label for the new window. Optional
        :type label: str

        :param tab: Tab to add to. Optional (default: current tab)
        :type tab: int

        :param hold_position: If True, then override Qt's default
                              placement and retain the original position
                              of new_widget
        :type hold_position: bool
        """

        # Find first tab that supports addSubWindow
        if tab is None:
            if hasattr(self.current_tab, 'addSubWindow'):
                pass
            else:
                for tab in range(self.tab_count):
                    page = self.tab(tab)
                    if hasattr(page, 'addSubWindow'):
                        break
                else:
                    self.new_tab()
                    tab = self.tab_count - 1

        page = self.tab(tab)
        pos = getattr(new_widget, 'position', None)
        sub = new_widget.mdi_wrap()

        sub.closed.connect(self._update_viewer_in_focus)

        if label:
            sub.setWindowTitle(label)
        page.addSubWindow(sub)
        page.setActiveSubWindow(sub)

        if hold_position and pos is not None:
            new_widget.move(pos[0], pos[1])

        self.tab_widget.setCurrentWidget(page)

        return sub

    def _edit_settings(self, *args):
        self._editor = PreferencesDialog(self, parent=self)
        self._editor.show()

    def gather_current_tab(self, *args):
        """Arrange windows in current tab via tiling"""
        self.current_tab.tileSubWindows()

    def _get_plot_dashboards(self, widget):

        if not isinstance(widget, DataViewer):
            return QtWidgets.QWidget(), QtWidgets.QWidget(), ""

        layer_view = widget.layer_view()
        options_widget = widget.options_widget()

        return layer_view, options_widget, str(widget)

    def _clear_dashboard(self):

        for widget, title in [(self._ui.plot_layers, "Plot Layers"),
                              (self._ui.plot_options, "Plot Options")]:
            layout = widget.layout()
            if layout is None:
                layout = QtWidgets.QVBoxLayout()
                layout.setContentsMargins(4, 4, 4, 4)
                widget.setLayout(layout)
            while layout.count():
                layout.takeAt(0).widget().hide()

        self._ui.plot_options_label.setText("Plot Options")
        self._ui.plot_layers_label.setText("Plot Layers")

    def _update_plot_dashboard(self, *args):

        self._clear_dashboard()

        if self._viewer_in_focus is None:
            return

        layer_view, options_widget, title = self._get_plot_dashboards(
            self._viewer_in_focus)

        layout = self._ui.plot_layers.layout()
        layout.addWidget(layer_view)

        layout = self._ui.plot_options.layout()
        layout.addWidget(options_widget)

        layer_view.show()
        options_widget.show()

        if title:
            self._ui.plot_options_label.setText("Plot Options - %s" % title)
            self._ui.plot_layers_label.setText("Plot Layers - %s" % title)
        else:
            self._ui.plot_options_label.setText("Plot Options")
            self._ui.plot_layers_label.setText("Plot Layers")

        self._update_focus_decoration()

    def _update_focus_decoration(self):
        mdi_area = self.current_tab
        for win in mdi_area.subWindowList():
            widget = win.widget()
            if isinstance(widget, DataViewer):
                widget.set_focus(widget is self._viewer_in_focus)

    def _connect(self):
        self.setAcceptDrops(True)
        self._layer_widget.setup(self._data)

        self.tab_widget.tabCloseRequested.connect(self.close_tab)
        self.tab_widget.currentChanged.connect(self._update_viewer_in_focus)

    def _create_menu(self):
        mbar = self.menuBar()
        menu = QtWidgets.QMenu(mbar)
        menu.setTitle("&File")

        menu.addAction(self._actions['data_new'])
        if 'data_importers' in self._actions:
            submenu = menu.addMenu("I&mport data")
            for a in self._actions['data_importers']:
                submenu.addAction(a)
        # menu.addAction(self._actions['data_save'])  # XXX add this
        menu.addAction(self._actions['session_reset'])
        menu.addAction(self._actions['session_restore'])
        menu.addAction(self._actions['session_save'])
        menu.addAction(self._actions['export_data'])
        if 'session_export' in self._actions:
            submenu = menu.addMenu("Advanced E&xporters")
            for a in self._actions['session_export']:
                submenu.addAction(a)
        menu.addSeparator()
        menu.addAction("Edit &Preferences", self._edit_settings)
        # Here we use close instead of self.app.quit because if we are launching
        # glue from an environment with a Qt event loop already existing, we
        # don't want to quit this. Using close here is safer, though it does
        # mean that any dialog we launch from glue has to be either modal (to
        # prevent quitting) or correctly define its parent so that it gets
        # closed too.
        menu.addAction("&Quit", self.close)
        mbar.addMenu(menu)

        menu = QtWidgets.QMenu(mbar)
        menu.setTitle("&Edit ")
        menu.addAction(self._actions['undo'])
        menu.addAction(self._actions['redo'])
        mbar.addMenu(menu)

        menu = QtWidgets.QMenu(mbar)
        menu.setTitle("&View ")

        a = QtWidgets.QAction("&Console Log", menu)
        a.triggered.connect(self._log._show)
        menu.addAction(a)
        mbar.addMenu(menu)

        menu = QtWidgets.QMenu(mbar)
        menu.setTitle("&Canvas")
        menu.addAction(self._actions['tab_new'])
        menu.addAction(self._actions['viewer_new'])
        menu.addAction(self._actions['fixed_layout_tab_new'])
        menu.addSeparator()
        menu.addAction(self._actions['gather'])
        menu.addAction(self._actions['tab_rename'])
        mbar.addMenu(menu)

        menu = QtWidgets.QMenu(mbar)
        menu.setTitle("Data &Manager")
        menu.addActions(self._layer_widget.actions())

        mbar.addMenu(menu)

        menu = QtWidgets.QMenu(mbar)
        menu.setTitle("&Plugins")
        menu.addAction(self._actions['plugin_manager'])
        menu.addSeparator()

        if 'plugins' in self._actions:
            for plugin in self._actions['plugins']:
                menu.addAction(plugin)

        mbar.addMenu(menu)

        # trigger inclusion of Mac Native "Help" tool
        menu = mbar.addMenu("&Help")

        a = QtWidgets.QAction("&Online Documentation", menu)
        a.triggered.connect(nonpartial(webbrowser.open, DOCS_URL))
        menu.addAction(a)

        a = QtWidgets.QAction("Send &Feedback", menu)
        a.triggered.connect(nonpartial(submit_feedback))
        menu.addAction(a)

        menu.addSeparator()
        menu.addAction("Version information", self._show_glue_info)

    def _show_glue_info(self):
        window = QVersionsDialog(parent=self)
        window.show()
        window.exec_()

    def _choose_save_data(self, *args):
        dialog = SaveDataDialog(data_collection=self.data_collection,
                                parent=self)
        dialog.exec_()

    def _create_actions(self):
        """ Create and connect actions, store in _actions dict """
        self._actions = {}

        a = action("&New Data Viewer",
                   self,
                   tip="Open a new visualization window in the current tab",
                   shortcut=QtGui.QKeySequence.New)
        a.triggered.connect(self._choose_new_data_viewer_nodata)
        self._actions['viewer_new'] = a

        if len(qt_client.members) == 0:
            a.setEnabled(False)

        a = action("New Fixed Layout Tab",
                   self,
                   tip="Create a new tab with a fixed layout")
        a.triggered.connect(self.choose_new_fixed_layout_tab)
        self._actions['fixed_layout_tab_new'] = a

        if len(qt_fixed_layout_tab.members) == 0:
            a.setEnabled(False)

        a = action('New &Tab',
                   self,
                   shortcut=QtGui.QKeySequence.AddTab,
                   tip='Add a new tab')
        a.triggered.connect(self.new_tab)
        self._actions['tab_new'] = a

        a = action('&Rename Tab',
                   self,
                   shortcut="Ctrl+R",
                   tip='Set a new label for the current tab')
        a.triggered.connect(nonpartial(self.tab_bar.choose_rename_tab))
        self._actions['tab_rename'] = a

        a = action('&Gather Windows',
                   self,
                   tip='Gather plot windows side-by-side',
                   shortcut='Ctrl+G')
        a.triggered.connect(self.gather_current_tab)
        self._actions['gather'] = a

        a = action('&Export Session', self, tip='Save the current session')
        a.triggered.connect(self._choose_save_session)
        self._actions['session_save'] = a

        # Add file loader as first item in File menu for convenience. We then
        # also add it again below in the Import menu for consistency.
        a = action("&Open Data Set",
                   self,
                   tip="Open a new data set",
                   shortcut=QtGui.QKeySequence.Open)
        a.triggered.connect(self._import_helper._choose_load_data_wizard)
        self._actions['data_new'] = a

        # We now populate the "Import data" menu
        from glue.config import importer

        acts = []

        # Add default file loader (later we can add this to the registry)
        a = action("Import from file", self, tip="Import from file")
        a.triggered.connect(self._import_helper._choose_load_data_wizard)
        acts.append(a)

        for label, data_importer in importer:
            a = action(label, self, tip=label)
            a.triggered.connect(
                nonpartial(self._import_helper._choose_load_data,
                           data_importer))
            acts.append(a)

        self._actions['data_importers'] = acts

        from glue.config import exporters
        if len(exporters) > 0:
            acts = []
            for e in exporters:
                label, saver, checker, mode = e
                a = action(label,
                           self,
                           tip='Export the current session to %s format' %
                           label)
                a.triggered.connect(
                    nonpartial(self._export_helper._choose_export_session,
                               saver, checker, mode))
                acts.append(a)

            self._actions['session_export'] = acts

        a = action('Open S&ession', self, tip='Restore a saved session')
        a.triggered.connect(self._restore_session)
        self._actions['session_restore'] = a

        a = action('Reset S&ession', self, tip='Reset session to clean state')
        a.triggered.connect(self._reset_session)
        self._actions['session_reset'] = a

        a = action('Export D&ata/Subsets', self, tip='Export data to a file')
        a.triggered.connect(self._choose_save_data)
        self._actions['export_data'] = a

        a = action("Undo",
                   self,
                   tip='Undo last action',
                   shortcut=QtGui.QKeySequence.Undo)
        a.triggered.connect(self.undo)
        a.setEnabled(False)
        self._actions['undo'] = a

        a = action("Redo",
                   self,
                   tip='Redo last action',
                   shortcut=QtGui.QKeySequence.Redo)
        a.triggered.connect(self.redo)
        a.setEnabled(False)
        self._actions['redo'] = a

        # Create actions for menubar plugins
        from glue.config import menubar_plugin
        acts = []
        for label, function in menubar_plugin:
            a = action(label, self, tip=label)
            a.triggered.connect(
                nonpartial(function, self.session, self.data_collection))
            acts.append(a)
        self._actions['plugins'] = acts

        a = action('&Plugin Manager', self, tip='Open plugin manager')
        a.triggered.connect(self.plugin_manager)
        self._actions['plugin_manager'] = a

    def undo(self, *args):
        super(GlueApplication, self).undo()

    def redo(self, *args):
        super(GlueApplication, self).redo()

    def choose_new_fixed_layout_tab(self, *args):
        """
        Creates a new tab with a fixed layout
        """

        tab_cls = pick_class(list(qt_fixed_layout_tab.members),
                             title='Fixed layout tab',
                             label="Choose a new fixed layout tab",
                             sort=True)

        return self.add_fixed_layout_tab(tab_cls)

    def add_fixed_layout_tab(self, tab_cls):

        tab = tab_cls(session=self.session)

        self._total_tab_count += 1

        name = 'Tab {0}'.format(self._total_tab_count)
        if hasattr(tab, 'LABEL'):
            name += ': ' + tab.LABEL
        self.tab_widget.addTab(tab, name)
        self.tab_widget.setCurrentWidget(tab)
        tab.subWindowActivated.connect(self._update_viewer_in_focus)

        return tab

    def _choose_new_data_viewer_nodata(self):
        self.choose_new_data_viewer()

    def choose_new_data_viewer(self, data=None):
        """ Create a new visualization window in the current tab
        """

        if data and data.ndim == 1 and ScatterViewer in qt_client.members:
            default = ScatterViewer
        elif data and data.ndim > 1 and ImageViewer in qt_client.members:
            default = ImageViewer
        else:
            default = None

        client = pick_class(list(qt_client.members),
                            title='Data Viewer',
                            label="Choose a new data viewer",
                            default=default,
                            sort=True)

        if client is None:
            return

        cmd = command.NewDataViewer(viewer=client, data=data)
        return self.do(cmd)

    @defer_draw
    def new_data_viewer(self, viewer_class, data=None, state=None):
        viewer = super(GlueApplication, self).new_data_viewer(viewer_class,
                                                              data=data,
                                                              state=state)
        if viewer is not None:
            viewer.show()
        return viewer

    def _choose_save_session(self, *args):
        """ Save the data collection and hub to file.

        Can be restored via restore_session
        """

        # include file filter twice, so it shows up in Dialog
        outfile, file_filter = compat.getsavefilename(
            parent=self,
            basedir=getattr(self, '_last_session_name', 'session.glu'),
            filters=("Glue Session with absolute paths to data (*.glu);; "
                     "Glue Session with relative paths to data (*.glu);; "
                     "Glue Session including data (*.glu)"),
            selectedfilter=getattr(
                self, '_last_session_filter',
                'Glue Session with relative paths to data (*.glu)'))

        # This indicates that the user cancelled
        if not outfile:
            return

        # Add extension if not specified
        if '.' not in outfile:
            outfile += '.glu'

        self._last_session_name = outfile
        self._last_session_filter = file_filter

        with set_cursor_cm(Qt.WaitCursor):
            self.save_session(outfile,
                              include_data="including data" in file_filter,
                              absolute_paths="absolute" in file_filter)

    @messagebox_on_error("Failed to restore session")
    def _restore_session(self, *args):
        """ Load a previously-saved state, and restart the session """
        fltr = "Glue sessions (*.glu)"
        file_name, file_filter = compat.getopenfilename(parent=self,
                                                        filters=fltr)
        if not file_name:
            return

        ga = self.restore_session_and_close(file_name)
        return ga

    @property
    def is_empty(self):
        """
        Returns `True` if there are no viewers and no data.
        """
        return (len([viewer for tab in self.viewers for viewer in tab]) == 0
                and len(self.data_collection) == 0)

    def _reset_session(self, *args, **kwargs):
        """
        Reset session to clean state.
        """

        warn = kwargs.pop('warn', False)

        if not os.environ.get('GLUE_TESTING') and warn and not self.is_empty:
            buttons = QtWidgets.QMessageBox.Ok | QtWidgets.QMessageBox.Cancel
            dialog = QtWidgets.QMessageBox.warning(
                self,
                "Confirm Close", "Are you sure you want to reset the session? "
                "This will close all datasets, subsets, and data viewers",
                buttons=buttons,
                defaultButton=QtWidgets.QMessageBox.Cancel)
            if not dialog == QtWidgets.QMessageBox.Ok:
                return

        # Make sure the closeEvent gets executed to close the GlueLogger
        self._log.close()
        if self.app is not None:
            self.app.processEvents()

        ga = GlueApplication()
        ga.start(block=False)

        # NOTE: we need to keep a reference to this new application object
        # otherwise it will immediately garbage collect - this is a hack and
        # we should find a better solution in future.
        self._new_application = ga

        # We need to close this after we open the next application otherwise
        # Qt will quit since there are no actively open windows.
        self.close()

        return ga

    @staticmethod
    def restore_session(path, show=True):
        """
        Reload a previously-saved session

        Parameters
        ----------
        path : str
            Path to the file to load
        show : bool, optional
            If True (the default), immediately show the widget

        Returns
        -------
        app : :class:`glue.app.qt.application.GlueApplication`
            The loaded application
        """
        ga = Application.restore_session(path)
        if show:
            ga.start(block=False)
        return ga

    def has_terminal(self, create_if_not=True):
        """
        Returns True if the IPython terminal is present.
        """
        if self._terminal is None and create_if_not:
            self._create_terminal()
        return self._terminal is not None

    def _create_terminal(self):

        if self._terminal is not None:  # already set up
            return

        try:
            widget = glue_terminal(data_collection=self._data,
                                   dc=self._data,
                                   hub=self._hub,
                                   session=self.session,
                                   application=self,
                                   **vars(env))
        except IPythonTerminalError:
            self._button_ipython.setEnabled(False)
        else:
            self._terminal = self.add_widget(widget)
            self._terminal.closed.connect(self._on_terminal_close)
            self._hide_terminal()

    def _toggle_terminal(self):
        if self._terminal is None:
            self._create_terminal()
        if self._terminal.isVisible():
            self._hide_terminal()
            if self._terminal.isVisible():
                warnings.warn("An unexpected error occurred while "
                              "trying to hide the terminal")
        else:
            self._show_terminal()
            if not self._terminal.isVisible():
                warnings.warn("An unexpected error occurred while "
                              "trying to show the terminal")

    def _on_terminal_close(self):
        if self._button_ipython.isChecked():
            self._button_ipython.blockSignals(True)
            self._button_ipython.setChecked(False)
            self._button_ipython.blockSignals(False)

    def _hide_terminal(self):
        self._terminal.hide()

    def _show_terminal(self):
        self._terminal.show()
        self._terminal.widget().show()

    def start(self, size=None, position=None, block=True, maximized=True):
        """
        Show the GUI and start the application.

        Parameters
        ----------
        size : (int, int) Optional
            The default width/height of the application.
            If not provided, uses the full screen
        position : (int, int) Optional
            The default position of the application
        """
        if maximized:
            self.showMaximized()
        else:
            self.show()
        if size is not None:
            self.resize(*size)
        if position is not None:
            self.move(*position)

        self.raise_()  # bring window to front

        # at some point during all this, the MPL backend
        # switches. This call restores things, so
        # figures are still inlined in the notebook.
        # XXX find out a better place for this
        _fix_ipython_pylab()

        if block:
            return self.app.exec_()

    exec_ = start

    def dragEnterEvent(self, event):
        if event.mimeData().hasUrls():
            event.accept()
        else:
            event.ignore()

    @messagebox_on_error("Failed to load files")
    def dropEvent(self, event):

        urls = event.mimeData().urls()

        paths = [qurl_to_path(url) for url in urls]

        if any(path.endswith('.glu') for path in paths):
            if len(paths) != 1:
                raise Exception("When dragging and dropping files onto glue, "
                                "only a single .glu session file can be "
                                "dropped at a time, or multiple datasets, but "
                                "not a mix of both.")
            else:
                self.restore_session_and_close(paths[0])
        else:
            self.load_data(paths)

        event.accept()

    @messagebox_on_error("Failed to restore session")
    def restore_session_and_close(self, path, warn=True):

        if warn and not self.is_empty:
            buttons = QtWidgets.QMessageBox.Ok | QtWidgets.QMessageBox.Cancel
            dialog = QtWidgets.QMessageBox.warning(
                self,
                "Confirm Close",
                "Loading a session file will close the existing session. Are you "
                "sure you want to continue?",
                buttons=buttons,
                defaultButton=QtWidgets.QMessageBox.Cancel)
            if not dialog == QtWidgets.QMessageBox.Ok:
                return

        with set_cursor_cm(Qt.WaitCursor):
            app = self.restore_session(path)
            app.setGeometry(self.geometry())

            # NOTE: we need to keep a reference to this new application object
            # otherwise it will immediately garbage collect - this is a hack and
            # we should find a better solution in future.
            self._new_application = app

            self.close()

    def closeEvent(self, event):
        """Emit a message to hub before closing."""

        # Clear the namespace in the terminal to avoid cicular references
        if self._terminal is not None:
            self._terminal.widget().clear_ns(
                ['data_collection', 'dc', 'hub', 'session', 'application'])

        for tab in self.viewers:
            for viewer in tab:
                viewer.close(warn=False)
        self._viewer_in_focus = None
        self._clear_dashboard()
        self._log.close()
        self._hub.broadcast(ApplicationClosedMessage(None))
        event.accept()
        if self._original_app is not None:
            self._original_app.setWindowIcon(self._original_icon)
            self._original_app = None
        self.app = None

    def report_error(self, message, detail):
        """
        Display an error in a modal

        :param message: A short description of the error
        :type message: str
        :param detail: A longer description
        :type detail: str
        """
        qmb = QtWidgets.QMessageBox(QtWidgets.QMessageBox.Critical, "Error",
                                    message)
        qmb.setDetailedText(detail)
        qmb.resize(400, qmb.size().height())
        qmb.exec_()

    def plugin_manager(self, *args):
        from glue.main import _installed_plugins
        pm = QtPluginManager(installed=_installed_plugins)
        pm.ui.exec_()

    def _update_undo_redo_enabled(self, *args):
        undo, redo = self._cmds.can_undo_redo()
        self._actions['undo'].setEnabled(undo)
        self._actions['redo'].setEnabled(redo)
        self._actions['undo'].setText('Undo ' + self._cmds.undo_label)
        self._actions['redo'].setText('Redo ' + self._cmds.redo_label)

    @property
    def viewers(self):
        """
        A list of lists of open Data Viewers.

        Each inner list contains the viewers open on a particular tab.
        """
        result = []
        for t in range(self.tab_count):
            tab = self.tab(t)
            item = []
            for subwindow in tab.subWindowList():
                widget = subwindow.widget()
                if isinstance(widget, DataViewer):
                    item.append(widget)
            result.append(tuple(item))
        return tuple(result)

    @property
    def tab_names(self):
        """
        The name of each tab

        A list of strings
        """
        return [self.tab_bar.tabText(i) for i in range(self.tab_count)]

    @tab_names.setter
    def tab_names(self, values):
        for index, value in enumerate(values):
            self.tab_bar.setTabText(index, value)

    @staticmethod
    def _choose_merge(data, others, suggested_label):

        w = load_ui('merge.ui', None, directory=os.path.dirname(__file__))
        w.button_yes.clicked.connect(w.accept)
        w.button_no.clicked.connect(w.reject)
        w.show()
        w.raise_()

        # Add the main dataset to the list. Some of the 'others' may also be
        # new ones, so it doesn't really make sense to distinguish between
        # the two here. The main point is that some datasets, including at
        # least one new one, have a common shape.
        others.append(data)
        others.sort(key=lambda x: x.label)
        for i, d in enumerate(others):
            if isinstance(d.coords, WCSCoordinates):
                if i == 0:
                    break
                else:
                    others[0], others[i] = others[i], others[0]
                    break

        w.merged_label.setText(suggested_label)

        entries = [QtWidgets.QListWidgetItem(other.label) for other in others]
        for e in entries:
            e.setCheckState(Qt.Checked)

        for d, item in zip(others, entries):
            w.choices.addItem(item)

        if not w.exec_():
            return None, None

        result = [
            layer for layer, entry in zip(others, entries)
            if entry.checkState() == Qt.Checked
        ]

        if result:
            return result, str(w.merged_label.text())

        return None, None

    def screenshot(self, filename):
        """
        Save a screenshot of the current application window to a file.
        """
        image = QtGui.QImage(self.size(), QtGui.QImage.Format_RGB32)
        painter = QtGui.QPainter(image)
        flags = self.IgnoreMask | self.DrawWindowBackground | self.DrawChildren
        self.render(painter, QtCore.QPoint(), QtGui.QRegion(), flags)
        image.save(filename)
        painter.end()

    def add_datasets(self, *args, **kwargs):
        result = super(GlueApplication, self).add_datasets(*args, **kwargs)
        run_autolinker(self.data_collection)
        return result

    def __gluestate__(self, context):
        state = super(GlueApplication, self).__gluestate__(context)
        state['tab_names'] = self.tab_names
        return state

    @classmethod
    def __setgluestate__(cls, rec, context):
        self = super(GlueApplication, cls).__setgluestate__(rec, context)
        if 'tab_names' in rec:
            self.tab_names = rec['tab_names']
        return self
Example #16
0
    def test_themes(self):

        # Check that themes work

        with patch('glue.config.settings') as settings:

            settings.FOREGROUND_COLOR = 'red'
            settings.BACKGROUND_COLOR = (0, 0.5, 1)
            settings.DATA_COLOR = (1, 0.5, 0.25)
            settings.DATA_ALPHA = 0.3

            dialog = PreferencesDialog(self.app)
            dialog.show()
            dialog.theme = 'White on Black'
            dialog.accept()

            assert rgb(settings.FOREGROUND_COLOR) == (1, 1, 1)
            assert rgb(settings.BACKGROUND_COLOR) == (0, 0, 0)
            assert rgb(settings.DATA_COLOR) == (0.75, 0.75, 0.75)
            assert settings.DATA_ALPHA == 0.8

            dialog = PreferencesDialog(self.app)
            dialog.show()
            dialog.theme = 'Black on White'
            dialog.accept()

            assert rgb(settings.FOREGROUND_COLOR) == (0, 0, 0)
            assert rgb(settings.BACKGROUND_COLOR) == (1, 1, 1)
            assert rgb(settings.DATA_COLOR) == (0.35, 0.35, 0.35)
            assert settings.DATA_ALPHA == 0.8
Example #17
0
    def test_custom_changes(self):

        # Check that themes work

        with patch('glue.config.settings') as settings:

            settings.FOREGROUND_COLOR = 'red'
            settings.BACKGROUND_COLOR = (0, 0.5, 1)
            settings.DATA_COLOR = (1, 0.5, 0.25)
            settings.DATA_ALPHA = 0.3

            dialog = PreferencesDialog(self.app)
            dialog.show()
            dialog.foreground = (0, 1, 1)
            dialog.accept()

            assert rgb(settings.FOREGROUND_COLOR) == (0, 1, 1)
            assert rgb(settings.BACKGROUND_COLOR) == (0, 0.5, 1)
            assert rgb(settings.DATA_COLOR) == (1, 0.5, 0.25)
            assert settings.DATA_ALPHA == 0.3

            dialog = PreferencesDialog(self.app)
            dialog.show()
            dialog.background = (1, 0, 1)
            dialog.accept()

            assert rgb(settings.FOREGROUND_COLOR) == (0, 1, 1)
            assert rgb(settings.BACKGROUND_COLOR) == (1, 0, 1)
            assert rgb(settings.DATA_COLOR) == (1, 0.5, 0.25)
            assert settings.DATA_ALPHA == 0.3

            dialog = PreferencesDialog(self.app)
            dialog.show()
            dialog.data_color = (1, 1, 0.5)
            dialog.accept()

            assert rgb(settings.FOREGROUND_COLOR) == (0, 1, 1)
            assert rgb(settings.BACKGROUND_COLOR) == (1, 0, 1)
            assert rgb(settings.DATA_COLOR) == (1, 1, 0.5)
            assert settings.DATA_ALPHA == 0.3

            dialog = PreferencesDialog(self.app)
            dialog.show()
            dialog.data_alpha = 0.4
            dialog.accept()

            assert rgb(settings.FOREGROUND_COLOR) == (0, 1, 1)
            assert rgb(settings.BACKGROUND_COLOR) == (1, 0, 1)
            assert rgb(settings.DATA_COLOR) == (1, 1, 0.5)
            assert settings.DATA_ALPHA == 0.4
Example #18
0
def test_foreground_background_settings():

    d_1d = Data(x=np.random.random(100), y=np.random.random(100), label='Data 1d')
    d_2d = Data(x=np.random.random((100, 100)), y=np.random.random((100, 100)), label='Data 2d')

    dc = DataCollection([d_1d, d_2d])

    app = GlueApplication(dc)

    # Make sure that settings change existing viewers, so we create a bunch of
    # viewers here.

    scatter1 = app.new_data_viewer(ScatterWidget)
    scatter1.add_data(d_1d)

    image1 = app.new_data_viewer(ImageWidget)
    image1.add_data(d_2d)

    histogram1 = app.new_data_viewer(HistogramWidget)
    histogram1.add_data(d_1d)

    dendrogram1 = app.new_data_viewer(DendroWidget)

    example_custom = _generate_custom_viewer()

    custom1 = app.new_data_viewer(example_custom)

    RED = (1, 0, 0, 0.5)
    GREEN = (0, 1, 0, 0.6)

    app.show()

    with patch('glue.config.settings') as settings:

        settings.FOREGROUND_COLOR = 'black'
        settings.BACKGROUND_COLOR = 'white'
        settings.DATA_COLOR = '0.5'
        settings.DATA_ALPHA = 0.5

        dialog = PreferencesDialog(app)
        dialog.show()
        dialog.background = RED
        dialog.foreground = GREEN
        dialog.accept()

        assert_axes_background(scatter1.axes, RED)
        assert_axes_background(image1.axes, RED)
        assert_axes_background(histogram1.axes, RED)
        assert_axes_background(dendrogram1.axes, RED)
        assert_axes_background(custom1.axes, RED)

        assert_axes_foreground(scatter1.axes, GREEN)
        assert_axes_foreground(image1.axes, GREEN)
        assert_axes_foreground(histogram1.axes, GREEN)
        assert_axes_foreground(dendrogram1.axes, GREEN)
        assert_axes_foreground(custom1.axes, GREEN)

        # Now make sure that new viewers also inherit these settings

        scatter2 = app.new_data_viewer(ScatterWidget)
        scatter2.add_data(d_1d)

        image2 = app.new_data_viewer(ImageWidget)
        image2.add_data(d_2d)

        histogram2 = app.new_data_viewer(HistogramWidget)
        histogram2.add_data(d_1d)

        dendrogram2 = app.new_data_viewer(DendroWidget)
        custom2 = app.new_data_viewer(example_custom)

        assert_axes_background(scatter2.axes, RED)
        assert_axes_background(image2.axes, RED)
        assert_axes_background(histogram2.axes, RED)
        assert_axes_background(dendrogram2.axes, RED)
        assert_axes_background(custom2.axes, RED)

        assert_axes_foreground(scatter2.axes, GREEN)
        assert_axes_foreground(image2.axes, GREEN)
        assert_axes_foreground(histogram2.axes, GREEN)
        assert_axes_foreground(dendrogram2.axes, GREEN)
        assert_axes_foreground(custom2.axes, GREEN)
Example #19
0
class GlueApplication(Application, QtWidgets.QMainWindow):

    """ The main GUI application for the Qt frontend"""

    def __init__(self, data_collection=None, session=None, maximized=True):

        self.app = get_qapp()

        QtWidgets.QMainWindow.__init__(self)
        Application.__init__(self, data_collection=data_collection,
                             session=session)

        self.app.setQuitOnLastWindowClosed(True)
        icon = get_icon('app_icon')
        self.app.setWindowIcon(icon)

        # Even though we loaded the plugins in start_glue, we re-load them here
        # in case glue was started directly by initializing this class.
        load_plugins()

        self.setWindowTitle("Glue")
        self.setWindowIcon(icon)
        self.setAttribute(Qt.WA_DeleteOnClose)
        self._actions = {}
        self._terminal = None
        self._setup_ui()
        self.tab_widget.setMovable(True)
        self.tab_widget.setTabsClosable(True)

        # The following is a counter that never goes down, even if tabs are
        # deleted (this is by design, to avoid having two tabs called the
        # same if a tab is removed then a new one added again)
        self._total_tab_count = 0

        lwidget = self._layer_widget
        a = PlotAction(lwidget, self)
        lwidget.ui.layerTree.addAction(a)
        lwidget.bind_selection_to_edit_subset()

        self._tweak_geometry(maximized=maximized)
        self._create_actions()
        self._create_menu()
        self._connect()
        self.new_tab()
        self._update_plot_dashboard(None)

    def _setup_ui(self):
        self._ui = load_ui('application.ui', None,
                           directory=os.path.dirname(__file__))
        self.setCentralWidget(self._ui)
        self._ui.tabWidget.setTabBar(GlueTabBar())

        lw = LayerTreeWidget()
        lw.set_checkable(False)
        self._vb = QtWidgets.QVBoxLayout()
        self._vb.setContentsMargins(0, 0, 0, 0)
        self._vb.addWidget(lw)
        self._ui.data_layers.setLayout(self._vb)
        self._layer_widget = lw

        # log window + status light
        self._log = GlueLogger()
        self._log.window().setWindowTitle("Console Log")
        self._log.resize(550, 550)
        self.statusBar().addPermanentWidget(self._log.status_light)
        self.statusBar().setContentsMargins(2, 0, 20, 2)
        self.statusBar().setSizeGripEnabled(False)

    def _tweak_geometry(self, maximized=True):
        """Maximize window by default."""
        if maximized:
            self.setWindowState(Qt.WindowMaximized)
        self._ui.main_splitter.setSizes([100, 800])
        self._ui.data_plot_splitter.setSizes([100, 150, 250])

    @property
    def tab_widget(self):
        return self._ui.tabWidget

    @property
    def tab_bar(self):
        return self._ui.tabWidget.tabBar()

    @property
    def tab_count(self):
        """
        The number of open tabs
        """
        return self._ui.tabWidget.count()

    @property
    def current_tab(self):
        return self._ui.tabWidget.currentWidget()

    def tab(self, index=None):
        if index is None:
            return self.current_tab
        return self._ui.tabWidget.widget(index)

    def new_tab(self):
        """Spawn a new tab page"""
        layout = QtWidgets.QGridLayout()
        layout.setSpacing(1)
        layout.setContentsMargins(0, 0, 0, 0)
        widget = GlueMdiArea(self)
        widget.setLayout(layout)
        tab = self.tab_widget
        self._total_tab_count += 1
        tab.addTab(widget, str("Tab %i" % self._total_tab_count))
        tab.setCurrentWidget(widget)
        widget.subWindowActivated.connect(self._update_plot_dashboard)

    def close_tab(self, index):
        """ Close a tab window and all associated data viewers """

        # do not delete the last tab
        if self.tab_widget.count() == 1:
            return

        if not os.environ.get('GLUE_TESTING'):
            buttons = QtWidgets.QMessageBox.Ok | QtWidgets.QMessageBox.Cancel
            dialog = QtWidgets.QMessageBox.warning(
                self, "Confirm Close",
                "Are you sure you want to close this tab? "
                "This will close all data viewers in the tab.",
                buttons=buttons, defaultButton=QtWidgets.QMessageBox.Cancel)
            if not dialog == QtWidgets.QMessageBox.Ok:
                return

        w = self.tab_widget.widget(index)

        for window in w.subWindowList():
            widget = window.widget()
            if isinstance(widget, DataViewer):
                widget.close(warn=False)

        w.close()

        self.tab_widget.removeTab(index)

    def add_widget(self, new_widget, label=None, tab=None,
                   hold_position=False):
        """
        Add a widget to one of the tabs.

        Returns the window that this widget is wrapped in.

        :param new_widget: new QtWidgets.QWidget to add

        :param label: label for the new window. Optional
        :type label: str

        :param tab: Tab to add to. Optional (default: current tab)
        :type tab: int

        :param hold_position: If True, then override Qt's default
                              placement and retain the original position
                              of new_widget
        :type hold_position: bool
        """
        page = self.tab(tab)
        pos = getattr(new_widget, 'position', None)
        sub = new_widget.mdi_wrap()

        sub.closed.connect(self._clear_dashboard)

        if label:
            sub.setWindowTitle(label)
        page.addSubWindow(sub)
        page.setActiveSubWindow(sub)
        if hold_position and pos is not None:
            new_widget.move(pos[0], pos[1])
        return sub

    def _edit_settings(self):
        self._editor = PreferencesDialog(self)
        self._editor.show()

    def gather_current_tab(self):
        """Arrange windows in current tab via tiling"""
        self.current_tab.tileSubWindows()

    def _get_plot_dashboards(self, sub_window):

        if not isinstance(sub_window, GlueMdiSubWindow):
            return QtWidgets.QWidget(), QtWidgets.QWidget(), ""

        widget = sub_window.widget()
        if not isinstance(widget, DataViewer):
            return QtWidgets.QWidget(), QtWidgets.QWidget(), ""

        layer_view = widget.layer_view()
        options_widget = widget.options_widget()

        return layer_view, options_widget, str(widget)

    def _clear_dashboard(self):

        for widget, title in [(self._ui.plot_layers, "Plot Layers"),
                              (self._ui.plot_options, "Plot Options")]:
            layout = widget.layout()
            if layout is None:
                layout = QtWidgets.QVBoxLayout()
                layout.setContentsMargins(4, 4, 4, 4)
                widget.setLayout(layout)
            while layout.count():
                layout.takeAt(0).widget().hide()
            widget.setTitle(title)

    def _update_plot_dashboard(self, sub_window):
        self._clear_dashboard()

        if sub_window is None:
            return

        layer_view, options_widget, title = self._get_plot_dashboards(
            sub_window)

        layout = self._ui.plot_layers.layout()
        layout.addWidget(layer_view)

        layout = self._ui.plot_options.layout()
        layout.addWidget(options_widget)

        layer_view.show()
        options_widget.show()

        if title:
            self._ui.plot_options.setTitle("Plot Options - %s" % title)
            self._ui.plot_layers.setTitle("Plot Layers - %s" % title)
        else:
            self._ui.plot_options.setTitle("Plot Options")
            self._ui.plot_layers.setTitle("Plot Layers")

        self._update_focus_decoration()

    def _update_focus_decoration(self):
        mdi_area = self.current_tab
        active = mdi_area.activeSubWindow()

        for win in mdi_area.subWindowList():
            widget = win.widget()
            if isinstance(widget, DataViewer):
                widget.set_focus(win is active)

    def _connect(self):
        self.setAcceptDrops(True)
        self._layer_widget.setup(self._data)

        self.tab_widget.tabCloseRequested.connect(self.close_tab)

    def _create_menu(self):
        mbar = self.menuBar()
        menu = QtWidgets.QMenu(mbar)
        menu.setTitle("&File")

        menu.addAction(self._actions['data_new'])
        if 'data_importers' in self._actions:
            submenu = menu.addMenu("I&mport data")
            for a in self._actions['data_importers']:
                submenu.addAction(a)
        # menu.addAction(self._actions['data_save'])  # XXX add this
        menu.addAction(self._actions['session_reset'])
        menu.addAction(self._actions['session_restore'])
        menu.addAction(self._actions['session_save'])
        if 'session_export' in self._actions:
            submenu = menu.addMenu("E&xport")
            for a in self._actions['session_export']:
                submenu.addAction(a)
        menu.addSeparator()
        menu.addAction("Edit &Preferences", self._edit_settings)
        menu.addAction("&Quit", self.app.quit)
        mbar.addMenu(menu)

        menu = QtWidgets.QMenu(mbar)
        menu.setTitle("&Edit ")
        menu.addAction(self._actions['undo'])
        menu.addAction(self._actions['redo'])
        mbar.addMenu(menu)

        menu = QtWidgets.QMenu(mbar)
        menu.setTitle("&View ")

        a = QtWidgets.QAction("&Console Log", menu)
        a.triggered.connect(self._log._show)
        menu.addAction(a)
        mbar.addMenu(menu)

        menu = QtWidgets.QMenu(mbar)
        menu.setTitle("&Canvas")
        menu.addAction(self._actions['tab_new'])
        menu.addAction(self._actions['viewer_new'])
        menu.addSeparator()
        menu.addAction(self._actions['gather'])
        menu.addAction(self._actions['tab_rename'])
        mbar.addMenu(menu)

        menu = QtWidgets.QMenu(mbar)
        menu.setTitle("Data &Manager")
        menu.addActions(self._layer_widget.actions())

        mbar.addMenu(menu)

        menu = QtWidgets.QMenu(mbar)
        menu.setTitle("&Toolbars")
        tbar = EditSubsetModeToolBar()
        self._mode_toolbar = tbar
        self.addToolBar(tbar)
        tbar.hide()
        a = QtWidgets.QAction("Selection Mode &Toolbar", menu)
        a.setCheckable(True)
        a.toggled.connect(tbar.setVisible)
        try:
            tbar.visibilityChanged.connect(a.setChecked)
        except AttributeError:  # Qt < 4.7. QtCore.Signal not supported
            pass

        menu.addAction(a)
        menu.addActions(tbar.actions())
        mbar.addMenu(menu)

        menu = QtWidgets.QMenu(mbar)
        menu.setTitle("&Plugins")
        menu.addAction(self._actions['plugin_manager'])
        menu.addSeparator()

        if 'plugins' in self._actions:
            for plugin in self._actions['plugins']:
                menu.addAction(plugin)

        mbar.addMenu(menu)

        # trigger inclusion of Mac Native "Help" tool
        menu = mbar.addMenu("&Help")

        a = QtWidgets.QAction("&Online Documentation", menu)
        a.triggered.connect(nonpartial(webbrowser.open, DOCS_URL))
        menu.addAction(a)

        a = QtWidgets.QAction("Send &Feedback", menu)
        a.triggered.connect(nonpartial(submit_feedback))
        menu.addAction(a)

        menu.addSeparator()
        menu.addAction("Version information", show_glue_info)

    def _choose_load_data(self, data_importer=None):
        if data_importer is None:
            self.add_datasets(self.data_collection, data_wizard())
        else:
            data = data_importer()
            if not isinstance(data, list):
                raise TypeError("Data loader should return list of "
                                "Data objects")
            for item in data:
                if not isinstance(item, Data):
                    raise TypeError("Data loader should return list of "
                                    "Data objects")
            self.add_datasets(self.data_collection, data)

    def _create_actions(self):
        """ Create and connect actions, store in _actions dict """
        self._actions = {}

        a = action("&New Data Viewer", self,
                   tip="Open a new visualization window in the current tab",
                   shortcut=QtGui.QKeySequence.New)
        a.triggered.connect(nonpartial(self.choose_new_data_viewer))
        self._actions['viewer_new'] = a

        a = action('New &Tab', self,
                   shortcut=QtGui.QKeySequence.AddTab,
                   tip='Add a new tab')
        a.triggered.connect(nonpartial(self.new_tab))
        self._actions['tab_new'] = a

        a = action('&Rename Tab', self,
                   shortcut="Ctrl+R",
                   tip='Set a new label for the current tab')
        a.triggered.connect(nonpartial(self.tab_bar.rename_tab))
        self._actions['tab_rename'] = a

        a = action('&Gather Windows', self,
                   tip='Gather plot windows side-by-side',
                   shortcut='Ctrl+G')
        a.triggered.connect(nonpartial(self.gather_current_tab))
        self._actions['gather'] = a

        a = action('&Save Session', self,
                   tip='Save the current session')
        a.triggered.connect(nonpartial(self._choose_save_session))
        self._actions['session_save'] = a

        # Add file loader as first item in File menu for convenience. We then
        # also add it again below in the Import menu for consistency.
        a = action("&Open Data Set", self, tip="Open a new data set",
                   shortcut=QtGui.QKeySequence.Open)
        a.triggered.connect(nonpartial(self._choose_load_data,
                                       data_wizard))
        self._actions['data_new'] = a

        # We now populate the "Import data" menu
        from glue.config import importer

        acts = []

        # Add default file loader (later we can add this to the registry)
        a = action("Import from file", self, tip="Import from file")
        a.triggered.connect(nonpartial(self._choose_load_data,
                                       data_wizard))
        acts.append(a)

        for i in importer:
            label, data_importer = i
            a = action(label, self, tip=label)
            a.triggered.connect(nonpartial(self._choose_load_data,
                                           data_importer))
            acts.append(a)

        self._actions['data_importers'] = acts

        from glue.config import exporters
        if len(exporters) > 0:
            acts = []
            for e in exporters:
                label, saver, checker, mode = e
                a = action(label, self,
                           tip='Export the current session to %s format' %
                           label)
                a.triggered.connect(nonpartial(self._choose_export_session,
                                               saver, checker, mode))
                acts.append(a)

            self._actions['session_export'] = acts

        a = action('Open S&ession', self,
                   tip='Restore a saved session')
        a.triggered.connect(nonpartial(self._restore_session))
        self._actions['session_restore'] = a

        a = action('Reset S&ession', self,
                   tip='Reset session to clean state')
        a.triggered.connect(nonpartial(self._reset_session))
        self._actions['session_reset'] = a

        a = action("Undo", self,
                   tip='Undo last action',
                   shortcut=QtGui.QKeySequence.Undo)
        a.triggered.connect(nonpartial(self.undo))
        a.setEnabled(False)
        self._actions['undo'] = a

        a = action("Redo", self,
                   tip='Redo last action',
                   shortcut=QtGui.QKeySequence.Redo)
        a.triggered.connect(nonpartial(self.redo))
        a.setEnabled(False)
        self._actions['redo'] = a

        # Create actions for menubar plugins
        from glue.config import menubar_plugin
        acts = []
        for label, function in menubar_plugin:
            a = action(label, self, tip=label)
            a.triggered.connect(nonpartial(function,
                                           self.session,
                                           self.data_collection))
            acts.append(a)
        self._actions['plugins'] = acts

        a = action('&Plugin Manager', self,
                   tip='Open plugin manager')
        a.triggered.connect(nonpartial(self.plugin_manager))
        self._actions['plugin_manager'] = a

    def choose_new_data_viewer(self, data=None):
        """ Create a new visualization window in the current tab
        """

        from glue.config import qt_client

        if data and data.ndim == 1 and ScatterWidget in qt_client.members:
            default = ScatterWidget
        elif data and data.ndim > 1 and ImageWidget in qt_client.members:
            default = ImageWidget
        else:
            default = None

        client = pick_class(list(qt_client.members), title='Data Viewer',
                            label="Choose a new data viewer",
                            default=default, sort=True)

        cmd = command.NewDataViewer(viewer=client, data=data)
        return self.do(cmd)

    new_data_viewer = defer_draw(Application.new_data_viewer)

    def _choose_save_session(self):
        """ Save the data collection and hub to file.

        Can be restored via restore_session
        """

        # include file filter twice, so it shows up in Dialog
        outfile, file_filter = compat.getsavefilename(
            parent=self, filters=("Glue Session (*.glu);; "
                                  "Glue Session including data (*.glu)"))

        # This indicates that the user cancelled
        if not outfile:
            return

        # Add extension if not specified
        if '.' not in outfile:
            outfile += '.glu'

        with set_cursor_cm(Qt.WaitCursor):
            self.save_session(
                outfile, include_data="including data" in file_filter)

    @messagebox_on_error("Failed to export session")
    def _choose_export_session(self, saver, checker, outmode):
        checker(self)
        if outmode is None:
            return saver(self)
        elif outmode in ['file', 'directory']:
            outfile, file_filter = compat.getsavefilename(parent=self)
            if not outfile:
                return
            return saver(self, outfile)
        else:
            assert outmode == 'label'
            label, ok = QtWidgets.QInputDialog.getText(self, 'Choose a label:',
                                                       'Choose a label:')
            if not ok:
                return
            return saver(self, label)

    @messagebox_on_error("Failed to restore session")
    def _restore_session(self, show=True):
        """ Load a previously-saved state, and restart the session """
        fltr = "Glue sessions (*.glu)"
        file_name, file_filter = compat.getopenfilename(
            parent=self, filters=fltr)
        if not file_name:
            return

        with set_cursor_cm(Qt.WaitCursor):
            ga = self.restore_session(file_name)
            self.close()
        return ga

    def _reset_session(self, show=True):
        """
        Reset session to clean state.
        """

        if not os.environ.get('GLUE_TESTING'):
            buttons = QtWidgets.QMessageBox.Ok | QtWidgets.QMessageBox.Cancel
            dialog = QtWidgets.QMessageBox.warning(
                self, "Confirm Close",
                "Are you sure you want to reset the session? "
                "This will close all datasets, subsets, and data viewers",
                buttons=buttons, defaultButton=QtWidgets.QMessageBox.Cancel)
            if not dialog == QtWidgets.QMessageBox.Ok:
                return

        ga = GlueApplication()
        ga.show()
        self.close()

        return ga

    @staticmethod
    def restore_session(path, show=True):
        """
        Reload a previously-saved session

        Parameters
        ----------
        path : str
            Path to the file to load
        show : bool, optional
            If True (the default), immediately show the widget

        Returns
        -------
        app : :class:`glue.app.qt.application.GlueApplication`
            The loaded application
        """
        ga = Application.restore_session(path)
        if show:
            ga.show()
        return ga

    def has_terminal(self):
        """
        Returns True if the IPython terminal is present.
        """
        self._create_terminal()  # ensure terminal is setup
        return self._terminal is not None

    def _create_terminal(self):

        if self._terminal is not None:  # already set up
            return

        if hasattr(self, '_terminal_exception'):  # already failed to set up
            return

        self._terminal_button = QtWidgets.QToolButton(self._ui)
        self._terminal_button.setToolTip("Toggle IPython Prompt")
        i = get_icon('IPythonConsole')
        self._terminal_button.setIcon(i)
        self._terminal_button.setIconSize(QtCore.QSize(25, 25))

        self._layer_widget.ui.button_row.addWidget(self._terminal_button)

        try:
            widget = glue_terminal(data_collection=self._data,
                                   dc=self._data,
                                   hub=self._hub,
                                   session=self.session,
                                   application=self,
                                   **vars(env))
        except IPythonTerminalError as exc:
            self._terminal = None
            self._terminal_button.setEnabled(False)
            self._terminal_button.setToolTip(str(exc))
            self._terminal_exception = str(exc)
            return

        self._terminal_button.clicked.connect(self._toggle_terminal)
        self._terminal = self.add_widget(widget)
        self._hide_terminal()

    def _setup_terminal_error_dialog(self, exception):
        """ Reassign the terminal toggle button to show dialog on error"""
        title = "Terminal unavailable"
        msg = ("Glue encountered an error trying to start the Terminal"
               "\nReason:\n%s" % exception)

        def show_msg():
            mb = QtWidgets.QMessageBox(QtWidgets.QMessageBox.Critical,
                             title, msg)
            mb.setDetailedText(self._terminal_exception)
            mb.exec_()

        self._terminal_button.clicked.connect(show_msg)

    def _toggle_terminal(self):
        if self._terminal.isVisible():
            self._hide_terminal()
            if self._terminal.isVisible():
                warnings.warn("An unexpected error occurred while "
                              "trying to hide the terminal")
        else:
            self._show_terminal()
            if not self._terminal.isVisible():
                warnings.warn("An unexpected error occurred while "
                              "trying to show the terminal")

    def _hide_terminal(self):
        self._terminal.hide()

    def _show_terminal(self):
        self._terminal.show()
        self._terminal.widget().show()

    def start(self, size=None, position=None):
        """
        Show the GUI and start the application.

        Parameters
        ----------
        size : (int, int) Optional
            The default width/height of the application.
            If not provided, uses the full screen
        position : (int, int) Optional
            The default position of the application
        """
        self._create_terminal()
        self.show()
        if size is not None:
            self.resize(*size)
        if position is not None:
            self.move(*position)

        self.raise_()  # bring window to front

        # at some point during all this, the MPL backend
        # switches. This call restores things, so
        # figures are still inlined in the notebook.
        # XXX find out a better place for this
        _fix_ipython_pylab()

        return self.app.exec_()

    exec_ = start

    def keyPressEvent(self, event):
        """Hold down modifier keys to temporarily set edit mode"""
        mod = event.modifiers()
        if mod == Qt.ShiftModifier:
            self._mode_toolbar.set_mode('or')

    def keyReleaseEvent(self, event):
        """Unset any temporary edit mode"""
        self._mode_toolbar.unset_mode()

    def dragEnterEvent(self, event):
        if event.mimeData().hasUrls():
            event.accept()
        else:
            event.ignore()

    def dropEvent(self, event):

        urls = event.mimeData().urls()

        for url in urls:

            # Get path to file
            path = url.path()

            # Workaround for a Qt bug that causes paths to start with a /
            # on Windows: https://bugreports.qt.io/browse/QTBUG-46417
            if sys.platform.startswith('win'):
                if path.startswith('/') and path[2] == ':':
                    path = path[1:]

            self.load_data(path)
        event.accept()

    def closeEvent(self, event):
        """Emit a message to hub before closing."""
        self._hub.broadcast(ApplicationClosedMessage(None))
        event.accept()

    def report_error(self, message, detail):
        """
        Display an error in a modal

        :param message: A short description of the error
        :type message: str
        :param detail: A longer description
        :type detail: str
        """
        qmb = QtWidgets.QMessageBox(QtWidgets.QMessageBox.Critical, "Error", message)
        qmb.setDetailedText(detail)
        qmb.resize(400, qmb.size().height())
        qmb.exec_()

    def plugin_manager(self):
        from glue.main import _installed_plugins
        pm = QtPluginManager(installed=_installed_plugins)
        pm.ui.exec_()

    def _update_undo_redo_enabled(self):
        undo, redo = self._cmds.can_undo_redo()
        self._actions['undo'].setEnabled(undo)
        self._actions['redo'].setEnabled(redo)
        self._actions['undo'].setText('Undo ' + self._cmds.undo_label)
        self._actions['redo'].setText('Redo ' + self._cmds.redo_label)

    @property
    def viewers(self):
        """
        A list of lists of open Data Viewers.

        Each inner list contains the viewers open on a particular tab.
        """
        result = []
        for t in range(self.tab_count):
            tab = self.tab(t)
            item = []
            for subwindow in tab.subWindowList():
                widget = subwindow.widget()
                if isinstance(widget, DataViewer):
                    item.append(widget)
            result.append(tuple(item))
        return tuple(result)

    @property
    def tab_names(self):
        """
        The name of each tab

        A list of strings
        """
        return [self.tab_bar.tabText(i) for i in range(self.tab_count)]

    @staticmethod
    def _choose_merge(data, others):

        w = load_ui('merge.ui', None, directory=os.path.dirname(__file__))
        w.button_yes.clicked.connect(w.accept)
        w.button_no.clicked.connect(w.reject)
        w.show()
        w.raise_()

        # Add the main dataset to the list. Some of the 'others' may also be
        # new ones, so it doesn't really make sense to distinguish between
        # the two here. The main point is that some datasets, including at
        # least one new one, have a common shape.
        others.append(data)
        others.sort(key=lambda x: x.label)

        label = others[0].label
        w.merged_label.setText(label)

        entries = [QtWidgets.QListWidgetItem(other.label) for other in others]
        for e in entries:
            e.setCheckState(Qt.Checked)

        for d, item in zip(others, entries):
            w.choices.addItem(item)

        if not w.exec_():
            return None, None

        result = [layer for layer, entry in zip(others, entries)
                  if entry.checkState() == Qt.Checked]

        if result:
            return result, str(w.merged_label.text())

        return None, None
Example #20
0
    def test_save_to_disk(self, tmpdir):

        with patch('glue.config.settings') as settings:
            with patch('glue.config.CFG_DIR', tmpdir.strpath):

                settings.FOREGROUND_COLOR = 'red'
                settings.BACKGROUND_COLOR = (0, 0.5, 1)
                settings.DATA_COLOR = (1, 0.5, 0.25)
                settings.DATA_ALPHA = 0.3

                dialog = PreferencesDialog(self.app)
                dialog.show()
                dialog.save_to_disk = False
                dialog.accept()

                assert not os.path.exists(
                    os.path.join(tmpdir.strpath, 'settings.cfg'))

                dialog = PreferencesDialog(self.app)
                dialog.show()
                dialog.save_to_disk = True
                dialog.accept()

                assert os.path.exists(
                    os.path.join(tmpdir.strpath, 'settings.cfg'))
Example #21
0
 def _edit_settings(self):
     self._editor = PreferencesDialog(self)
     self._editor.show()
Example #22
0
    def test_save_to_disk(self, tmpdir):

        with patch('glue.config.settings') as settings:
            with patch('glue.config.CFG_DIR', tmpdir.strpath):

                settings.FOREGROUND_COLOR = 'red'
                settings.BACKGROUND_COLOR = (0, 0.5, 1)
                settings.DATA_COLOR = (1, 0.5, 0.25)
                settings.DATA_ALPHA = 0.3

                dialog = PreferencesDialog(self.app)
                dialog.show()
                dialog.save_to_disk = False
                dialog.accept()

                assert not os.path.exists(os.path.join(tmpdir.strpath, 'settings.cfg'))

                dialog = PreferencesDialog(self.app)
                dialog.show()
                dialog.save_to_disk = True
                dialog.accept()

                assert os.path.exists(os.path.join(tmpdir.strpath, 'settings.cfg'))
Example #23
0
 def _edit_settings(self, *args):
     self._editor = PreferencesDialog(self, parent=self)
     self._editor.show()
Example #24
0
class GlueApplication(Application, QtWidgets.QMainWindow):

    """ The main GUI application for the Qt frontend"""

    def __init__(self, data_collection=None, session=None):

        # At this point we need to check if a Qt application already exists -
        # this happens for example if using the %gui qt/qt5 mode in Jupyter. We
        # should keep a reference to the original icon so that we can restore it
        # later
        self._original_app = QtWidgets.QApplication.instance()
        if self._original_app is not None:
            self._original_icon = self._original_app.windowIcon()

        self._export_helper = ExportHelper(self)
        self._import_helper = ImportHelper(self)

        # Now we can get the application instance, which involves setting it
        # up if it doesn't already exist.
        self.app = get_qapp()

        QtWidgets.QMainWindow.__init__(self)
        Application.__init__(self, data_collection=data_collection,
                             session=session)

        # Pull in any keybindings from an external file
        self.keybindings = keyboard_shortcut

        icon = get_icon('app_icon')
        self.app.setWindowIcon(icon)

        # Even though we loaded the plugins in start_glue, we re-load them here
        # in case glue was started directly by initializing this class.
        load_plugins()

        self.setWindowTitle("Glue")
        self.setWindowIcon(icon)
        self.setAttribute(Qt.WA_DeleteOnClose)
        self._actions = {}
        self._terminal = None
        self._setup_ui()
        self.tab_widget.setMovable(True)
        self.tab_widget.setTabsClosable(True)

        # The following is a counter that never goes down, even if tabs are
        # deleted (this is by design, to avoid having two tabs called the
        # same if a tab is removed then a new one added again)
        self._total_tab_count = 0

        lwidget = self._layer_widget
        a = PlotAction(lwidget, self)
        lwidget.ui.layerTree.addAction(a)

        self._tweak_geometry()
        self._create_actions()
        self._create_menu()
        self._connect()
        self.new_tab()
        self._update_viewer_in_focus()

    def _update_viewer_in_focus(self, *args):

        if not hasattr(self, '_viewer_in_focus'):
            self._viewer_in_focus = None

        mdi_area = self.current_tab
        active = mdi_area.activeSubWindow()

        # Disable any active tool in the viewer that was previously in focus.
        # Note that we want to do this even if active is None, which means that
        # the user may have switched application.
        if (self._viewer_in_focus is not None and
                (active is None or active.widget() is not self._viewer_in_focus)):
            try:
                self._viewer_in_focus.toolbar.active_tool = None
            except AttributeError:
                pass  # not all viewers have toolbars

        if active is None:
            first_viewer = None
            for win in mdi_area.subWindowList():
                if self._viewer_in_focus is win.widget():
                    break
                elif isinstance(win.widget(), DataViewer):
                    first_viewer = win.widget()
            else:
                self._viewer_in_focus = first_viewer
                self._update_focus_decoration()
                self._update_plot_dashboard()
        else:
            self._viewer_in_focus = active.widget()
            self._update_focus_decoration()
            self._update_plot_dashboard()

    def run_startup_action(self, name):
        if name in startup_action.members:
            startup_action.members[name](self.session, self.data_collection)
        else:
            raise Exception("Unknown startup action: {0}".format(name))

    def _setup_ui(self):
        self._ui = load_ui('application.ui', None,
                           directory=os.path.dirname(__file__))
        self.setCentralWidget(self._ui)
        self._ui.tabWidget.setTabBar(GlueTabBar())

        lw = LayerTreeWidget(session=self._session)
        lw.set_checkable(False)
        self._vb = QtWidgets.QVBoxLayout()
        self._vb.setContentsMargins(0, 0, 0, 0)
        self._vb.addWidget(lw)
        self._ui.data_layers.setLayout(self._vb)
        self._layer_widget = lw

        # Data toolbar

        self._data_toolbar = QtWidgets.QToolBar("Session and Data toolbar")

        self._data_toolbar.setIconSize(QtCore.QSize(16, 16))

        self._button_open_session = QtWidgets.QToolButton()
        self._button_open_session.setText("Open Session")
        self._button_open_session.setIcon(get_icon('glue_open'))
        self._button_open_session.setToolButtonStyle(Qt.ToolButtonTextBesideIcon)
        self._button_open_session.clicked.connect(self._restore_session)

        self._data_toolbar.addWidget(self._button_open_session)

        self._button_save_session = QtWidgets.QToolButton()
        self._button_save_session.setText("Export Session")
        self._button_save_session.setIcon(get_icon('glue_filesave'))
        self._button_save_session.setToolButtonStyle(Qt.ToolButtonTextBesideIcon)
        self._button_save_session.clicked.connect(self._choose_save_session)

        self._data_toolbar.addWidget(self._button_save_session)
        self._button_open_data = QtWidgets.QToolButton()
        self._button_open_data.setText("Import Data")
        self._button_open_data.setIcon(get_icon('glue_open'))
        self._button_open_data.setToolButtonStyle(Qt.ToolButtonTextBesideIcon)
        self._button_open_data.clicked.connect(self._import_helper._choose_load_data_wizard)

        self._data_toolbar.addWidget(self._button_open_data)

        self._button_save_data = QtWidgets.QToolButton()
        self._button_save_data.setText("Export Data/Subsets")
        self._button_save_data.setIcon(get_icon('glue_filesave'))
        self._button_save_data.setToolButtonStyle(Qt.ToolButtonTextBesideIcon)
        self._button_save_data.clicked.connect(self._choose_save_data)

        self._data_toolbar.addWidget(self._button_save_data)

        self._button_link_data = QtWidgets.QToolButton()
        self._button_link_data.setText("Link Data")
        self._button_link_data.setIcon(get_icon('glue_link'))
        self._button_link_data.setToolButtonStyle(Qt.ToolButtonTextBesideIcon)
        self._button_link_data.clicked.connect(self._set_up_links)

        self._data_toolbar.addWidget(self._button_link_data)

        self._button_edit_components = QtWidgets.QToolButton()
        self._button_edit_components.setText("Arithmetic attributes")
        self._button_edit_components.setIcon(get_icon('arithmetic'))
        self._button_edit_components.setToolButtonStyle(Qt.ToolButtonTextBesideIcon)
        self._button_edit_components.clicked.connect(self._artihmetic_dialog)

        self._data_toolbar.addWidget(self._button_edit_components)

        self.addToolBar(self._data_toolbar)

        self._on_data_collection_change()

        # Selection mode toolbar

        tbar = EditSubsetModeToolBar(parent=self)
        self._mode_toolbar = tbar

        self.addToolBar(self._mode_toolbar)

        # Error console toolbar

        self._console_toolbar = QtWidgets.QToolBar('Advanced toolbar')

        self._console_toolbar.setIconSize(QtCore.QSize(14, 14))

        spacer = QtWidgets.QWidget()
        spacer.setSizePolicy(QtWidgets.QSizePolicy.Expanding,
                             QtWidgets.QSizePolicy.Preferred)

        self._console_toolbar.addWidget(spacer)

        self._button_ipython = QtWidgets.QToolButton()
        self._button_ipython.setCheckable(True)
        self._button_ipython.setText("Terminal")
        self._button_ipython.setIcon(get_icon('IPythonConsole'))
        self._button_ipython.setToolButtonStyle(Qt.ToolButtonTextBesideIcon)
        self._button_ipython.clicked.connect(self._toggle_terminal)

        self._console_toolbar.addWidget(self._button_ipython)

        self._button_preferences = QtWidgets.QToolButton()
        self._button_preferences.setText("Preferences")
        self._button_preferences.setIcon(get_icon('glue_settings'))
        self._button_preferences.setToolButtonStyle(Qt.ToolButtonTextBesideIcon)
        self._button_preferences.clicked.connect(self._edit_settings)

        self._console_toolbar.addWidget(self._button_preferences)

        self._button_console = QtWidgets.QToolButton()
        self._button_console.setText("Error Console")
        self._button_console.setToolButtonStyle(Qt.ToolButtonTextOnly)

        self._console_toolbar.addWidget(self._button_console)

        self.addToolBar(self._console_toolbar)

        self._log = GlueLogger(button_console=self._button_console)
        self._log.window().setWindowTitle("Console Log")
        self._log.resize(550, 550)
        self._log.hide()

        self._hub.subscribe(self, DataCollectionMessage, handler=self._on_data_collection_change)
        self._hub.subscribe(self, SettingsChangeMessage, handler=self._on_ui_settings_change)

    def _artihmetic_dialog(self, *event):
        dialog = ArithmeticEditorWidget(self.data_collection)
        dialog.exec_()

    def _on_data_collection_change(self, *event):
        self._button_save_data.setEnabled(len(self.data_collection) > 0)
        self._button_link_data.setEnabled(len(self.data_collection) > 1)
        self._button_edit_components.setEnabled(len(self.data_collection) > 0)

    def _on_ui_settings_change(self, *event):
        update_global_font_size()

    def keyPressEvent(self, event):
        if self.current_tab.activeSubWindow() and self.current_tab.activeSubWindow().widget():
            active_window = self.current_tab.activeSubWindow().widget()
        else:
            active_window = None

        # keybindings is a data structure in the form of dict(dict)
        # which uses the DataViewer as the first key, the key pressed
        # as the second key, and the function associated with those two
        # as the value.
        if type(active_window) in self.keybindings:
            for k, func in self.keybindings.members[type(active_window)].items():
                if event.key() == k:
                    func(self.session)
                    return

        # If key does not correspond with viewers, it might correspond
        # with the global application, thus, None
        if None in self.keybindings:
            for k, func in self.keybindings.members[None].items():
                if event.key() == k:
                    func(self.session)
                    return

        return super(GlueApplication, self).keyPressEvent(event)

    def _set_up_links(self, event):
        LinkEditor.update_links(self.data_collection)

    def _tweak_geometry(self):
        """Maximize window by default."""
        self._ui.main_splitter.setStretchFactor(0, 0.1)
        self._ui.main_splitter.setStretchFactor(1, 0.9)
        self._ui.data_plot_splitter.setStretchFactor(0, 0.25)
        self._ui.data_plot_splitter.setStretchFactor(1, 0.5)
        self._ui.data_plot_splitter.setStretchFactor(2, 0.25)

    @property
    def tab_widget(self):
        return self._ui.tabWidget

    @property
    def tab_bar(self):
        return self._ui.tabWidget.tabBar()

    @property
    def tab_count(self):
        """
        The number of open tabs
        """
        return self._ui.tabWidget.count()

    @property
    def current_tab(self):
        return self._ui.tabWidget.currentWidget()

    def get_tab_index(self, widget):
        for idx in range(self.tab_count):
            if self.tab(idx) == widget:
                return idx
        raise Exception("Tab not found")

    def tab(self, index=None):
        if index is None:
            return self.current_tab
        return self._ui.tabWidget.widget(index)

    def new_tab(self, *args):
        """Spawn a new tab page"""
        layout = QtWidgets.QGridLayout()
        layout.setSpacing(1)
        layout.setContentsMargins(0, 0, 0, 0)
        widget = GlueMdiArea(self)
        widget.setLayout(layout)
        tab = self.tab_widget
        self._total_tab_count += 1
        tab.addTab(widget, str("Tab %i" % self._total_tab_count))
        tab.setCurrentWidget(widget)
        widget.subWindowActivated.connect(self._update_viewer_in_focus)

    def close_tab(self, index, warn=True):
        """ Close a tab window and all associated data viewers """

        # do not delete the last tab
        if self.tab_widget.count() == 1:
            return

        w = self.tab_widget.widget(index)

        if len(w.subWindowList()) > 0:

            if warn and not os.environ.get('GLUE_TESTING'):
                buttons = QtWidgets.QMessageBox.Ok | QtWidgets.QMessageBox.Cancel
                dialog = QtWidgets.QMessageBox.warning(
                    self, "Confirm Close",
                    "Are you sure you want to close this tab? "
                    "This will close all data viewers in the tab.",
                    buttons=buttons, defaultButton=QtWidgets.QMessageBox.Cancel)
                if not dialog == QtWidgets.QMessageBox.Ok:
                    return

            for window in w.subWindowList():
                widget = window.widget()
                if isinstance(widget, DataViewer):
                    widget.close(warn=False)

        w.close()

        self.tab_widget.removeTab(index)

    def add_widget(self, new_widget, label=None, tab=None,
                   hold_position=False):
        """
        Add a widget to one of the tabs.

        Returns the window that this widget is wrapped in.

        :param new_widget: new QtWidgets.QWidget to add

        :param label: label for the new window. Optional
        :type label: str

        :param tab: Tab to add to. Optional (default: current tab)
        :type tab: int

        :param hold_position: If True, then override Qt's default
                              placement and retain the original position
                              of new_widget
        :type hold_position: bool
        """

        # Find first tab that supports addSubWindow
        if tab is None:
            if hasattr(self.current_tab, 'addSubWindow'):
                pass
            else:
                for tab in range(self.tab_count):
                    page = self.tab(tab)
                    if hasattr(page, 'addSubWindow'):
                        break
                else:
                    self.new_tab()
                    tab = self.tab_count - 1

        page = self.tab(tab)
        pos = getattr(new_widget, 'position', None)
        sub = new_widget.mdi_wrap()

        sub.closed.connect(self._update_viewer_in_focus)

        if label:
            sub.setWindowTitle(label)
        page.addSubWindow(sub)
        page.setActiveSubWindow(sub)

        if hold_position and pos is not None:
            new_widget.move(pos[0], pos[1])

        self.tab_widget.setCurrentWidget(page)

        return sub

    def _edit_settings(self, *args):
        self._editor = PreferencesDialog(self, parent=self)
        self._editor.show()

    def gather_current_tab(self, *args):
        """Arrange windows in current tab via tiling"""
        self.current_tab.tileSubWindows()

    def _get_plot_dashboards(self, widget):

        if not isinstance(widget, DataViewer):
            return QtWidgets.QWidget(), QtWidgets.QWidget(), ""

        layer_view = widget.layer_view()
        options_widget = widget.options_widget()

        return layer_view, options_widget, str(widget)

    def _clear_dashboard(self):

        for widget, title in [(self._ui.plot_layers, "Plot Layers"),
                              (self._ui.plot_options, "Plot Options")]:
            layout = widget.layout()
            if layout is None:
                layout = QtWidgets.QVBoxLayout()
                layout.setContentsMargins(4, 4, 4, 4)
                widget.setLayout(layout)
            while layout.count():
                layout.takeAt(0).widget().hide()

        self._ui.plot_options_label.setText("Plot Options")
        self._ui.plot_layers_label.setText("Plot Layers")

    def _update_plot_dashboard(self, *args):

        self._clear_dashboard()

        if self._viewer_in_focus is None:
            return

        layer_view, options_widget, title = self._get_plot_dashboards(self._viewer_in_focus)

        layout = self._ui.plot_layers.layout()
        layout.addWidget(layer_view)

        layout = self._ui.plot_options.layout()
        layout.addWidget(options_widget)

        layer_view.show()
        options_widget.show()

        if title:
            self._ui.plot_options_label.setText("Plot Options - %s" % title)
            self._ui.plot_layers_label.setText("Plot Layers - %s" % title)
        else:
            self._ui.plot_options_label.setText("Plot Options")
            self._ui.plot_layers_label.setText("Plot Layers")

        self._update_focus_decoration()

    def _update_focus_decoration(self):
        mdi_area = self.current_tab
        for win in mdi_area.subWindowList():
            widget = win.widget()
            if isinstance(widget, DataViewer):
                widget.set_focus(widget is self._viewer_in_focus)

    def _connect(self):
        self.setAcceptDrops(True)
        self._layer_widget.setup(self._data)

        self.tab_widget.tabCloseRequested.connect(self.close_tab)
        self.tab_widget.currentChanged.connect(self._update_viewer_in_focus)

    def _create_menu(self):
        mbar = self.menuBar()
        menu = QtWidgets.QMenu(mbar)
        menu.setTitle("&File")

        menu.addAction(self._actions['data_new'])
        if 'data_importers' in self._actions:
            submenu = menu.addMenu("I&mport data")
            for a in self._actions['data_importers']:
                submenu.addAction(a)
        # menu.addAction(self._actions['data_save'])  # XXX add this
        menu.addAction(self._actions['session_reset'])
        menu.addAction(self._actions['session_restore'])
        menu.addAction(self._actions['session_save'])
        menu.addAction(self._actions['export_data'])
        if 'session_export' in self._actions:
            submenu = menu.addMenu("Advanced E&xporters")
            for a in self._actions['session_export']:
                submenu.addAction(a)
        menu.addSeparator()
        menu.addAction("Edit &Preferences", self._edit_settings)
        # Here we use close instead of self.app.quit because if we are launching
        # glue from an environment with a Qt event loop already existing, we
        # don't want to quit this. Using close here is safer, though it does
        # mean that any dialog we launch from glue has to be either modal (to
        # prevent quitting) or correctly define its parent so that it gets
        # closed too.
        menu.addAction("&Quit", self.close)
        mbar.addMenu(menu)

        menu = QtWidgets.QMenu(mbar)
        menu.setTitle("&Edit ")
        menu.addAction(self._actions['undo'])
        menu.addAction(self._actions['redo'])
        mbar.addMenu(menu)

        menu = QtWidgets.QMenu(mbar)
        menu.setTitle("&View ")

        a = QtWidgets.QAction("&Console Log", menu)
        a.triggered.connect(self._log._show)
        menu.addAction(a)
        mbar.addMenu(menu)

        menu = QtWidgets.QMenu(mbar)
        menu.setTitle("&Canvas")
        menu.addAction(self._actions['tab_new'])
        menu.addAction(self._actions['viewer_new'])
        menu.addAction(self._actions['fixed_layout_tab_new'])
        menu.addSeparator()
        menu.addAction(self._actions['gather'])
        menu.addAction(self._actions['tab_rename'])
        mbar.addMenu(menu)

        menu = QtWidgets.QMenu(mbar)
        menu.setTitle("Data &Manager")
        menu.addActions(self._layer_widget.actions())

        mbar.addMenu(menu)

        menu = QtWidgets.QMenu(mbar)
        menu.setTitle("&Plugins")
        menu.addAction(self._actions['plugin_manager'])
        menu.addSeparator()

        if 'plugins' in self._actions:
            for plugin in self._actions['plugins']:
                menu.addAction(plugin)

        mbar.addMenu(menu)

        # trigger inclusion of Mac Native "Help" tool
        menu = mbar.addMenu("&Help")

        a = QtWidgets.QAction("&Online Documentation", menu)
        a.triggered.connect(nonpartial(webbrowser.open, DOCS_URL))
        menu.addAction(a)

        a = QtWidgets.QAction("Send &Feedback", menu)
        a.triggered.connect(nonpartial(submit_feedback))
        menu.addAction(a)

        menu.addSeparator()
        menu.addAction("Version information", self._show_glue_info)

    def _show_glue_info(self):
        window = QVersionsDialog(parent=self)
        window.show()
        window.exec_()

    def _choose_save_data(self, *args):
        dialog = SaveDataDialog(data_collection=self.data_collection, parent=self)
        dialog.exec_()

    def _create_actions(self):
        """ Create and connect actions, store in _actions dict """
        self._actions = {}

        a = action("&New Data Viewer", self,
                   tip="Open a new visualization window in the current tab",
                   shortcut=QtGui.QKeySequence.New)
        a.triggered.connect(self._choose_new_data_viewer_nodata)
        self._actions['viewer_new'] = a

        if len(qt_client.members) == 0:
            a.setEnabled(False)

        a = action("New Fixed Layout Tab", self,
                   tip="Create a new tab with a fixed layout")
        a.triggered.connect(self.choose_new_fixed_layout_tab)
        self._actions['fixed_layout_tab_new'] = a

        if len(qt_fixed_layout_tab.members) == 0:
            a.setEnabled(False)

        a = action('New &Tab', self,
                   shortcut=QtGui.QKeySequence.AddTab,
                   tip='Add a new tab')
        a.triggered.connect(self.new_tab)
        self._actions['tab_new'] = a

        a = action('&Rename Tab', self,
                   shortcut="Ctrl+R",
                   tip='Set a new label for the current tab')
        a.triggered.connect(nonpartial(self.tab_bar.choose_rename_tab))
        self._actions['tab_rename'] = a

        a = action('&Gather Windows', self,
                   tip='Gather plot windows side-by-side',
                   shortcut='Ctrl+G')
        a.triggered.connect(self.gather_current_tab)
        self._actions['gather'] = a

        a = action('&Export Session', self,
                   tip='Save the current session')
        a.triggered.connect(self._choose_save_session)
        self._actions['session_save'] = a

        # Add file loader as first item in File menu for convenience. We then
        # also add it again below in the Import menu for consistency.
        a = action("&Open Data Set", self, tip="Open a new data set",
                   shortcut=QtGui.QKeySequence.Open)
        a.triggered.connect(self._import_helper._choose_load_data_wizard)
        self._actions['data_new'] = a

        # We now populate the "Import data" menu
        from glue.config import importer

        acts = []

        # Add default file loader (later we can add this to the registry)
        a = action("Import from file", self, tip="Import from file")
        a.triggered.connect(self._import_helper._choose_load_data_wizard)
        acts.append(a)

        for label, data_importer in importer:
            a = action(label, self, tip=label)
            a.triggered.connect(nonpartial(self._import_helper._choose_load_data, data_importer))
            acts.append(a)

        self._actions['data_importers'] = acts

        from glue.config import exporters
        if len(exporters) > 0:
            acts = []
            for e in exporters:
                label, saver, checker, mode = e
                a = action(label, self,
                           tip='Export the current session to %s format' %
                           label)
                a.triggered.connect(nonpartial(self._export_helper._choose_export_session,
                                               saver, checker, mode))
                acts.append(a)

            self._actions['session_export'] = acts

        a = action('Open S&ession', self,
                   tip='Restore a saved session')
        a.triggered.connect(self._restore_session)
        self._actions['session_restore'] = a

        a = action('Reset S&ession', self,
                   tip='Reset session to clean state')
        a.triggered.connect(self._reset_session)
        self._actions['session_reset'] = a

        a = action('Export D&ata/Subsets', self,
                   tip='Export data to a file')
        a.triggered.connect(self._choose_save_data)
        self._actions['export_data'] = a

        a = action("Undo", self,
                   tip='Undo last action',
                   shortcut=QtGui.QKeySequence.Undo)
        a.triggered.connect(self.undo)
        a.setEnabled(False)
        self._actions['undo'] = a

        a = action("Redo", self,
                   tip='Redo last action',
                   shortcut=QtGui.QKeySequence.Redo)
        a.triggered.connect(self.redo)
        a.setEnabled(False)
        self._actions['redo'] = a

        # Create actions for menubar plugins
        from glue.config import menubar_plugin
        acts = []
        for label, function in menubar_plugin:
            a = action(label, self, tip=label)
            a.triggered.connect(nonpartial(function,
                                           self.session,
                                           self.data_collection))
            acts.append(a)
        self._actions['plugins'] = acts

        a = action('&Plugin Manager', self,
                   tip='Open plugin manager')
        a.triggered.connect(self.plugin_manager)
        self._actions['plugin_manager'] = a

    def undo(self, *args):
        super(GlueApplication, self).undo()

    def redo(self, *args):
        super(GlueApplication, self).redo()

    def choose_new_fixed_layout_tab(self, *args):
        """
        Creates a new tab with a fixed layout
        """

        tab_cls = pick_class(list(qt_fixed_layout_tab.members), title='Fixed layout tab',
                             label="Choose a new fixed layout tab",
                             sort=True)

        return self.add_fixed_layout_tab(tab_cls)

    def add_fixed_layout_tab(self, tab_cls):

        tab = tab_cls(session=self.session)

        self._total_tab_count += 1

        name = 'Tab {0}'.format(self._total_tab_count)
        if hasattr(tab, 'LABEL'):
            name += ': ' + tab.LABEL
        self.tab_widget.addTab(tab, name)
        self.tab_widget.setCurrentWidget(tab)
        tab.subWindowActivated.connect(self._update_viewer_in_focus)

        return tab

    def _choose_new_data_viewer_nodata(self):
        self.choose_new_data_viewer()

    def choose_new_data_viewer(self, data=None):
        """ Create a new visualization window in the current tab
        """

        if data and data.ndim == 1 and ScatterViewer in qt_client.members:
            default = ScatterViewer
        elif data and data.ndim > 1 and ImageViewer in qt_client.members:
            default = ImageViewer
        else:
            default = None

        client = pick_class(list(qt_client.members), title='Data Viewer',
                            label="Choose a new data viewer",
                            default=default, sort=True)

        if client is None:
            return

        cmd = command.NewDataViewer(viewer=client, data=data)
        return self.do(cmd)

    @defer_draw
    def new_data_viewer(self, viewer_class, data=None, state=None):
        viewer = super(GlueApplication, self).new_data_viewer(viewer_class, data=data, state=state)
        if viewer is not None:
            viewer.show()
        return viewer

    def _choose_save_session(self, *args):
        """ Save the data collection and hub to file.

        Can be restored via restore_session
        """

        # include file filter twice, so it shows up in Dialog
        outfile, file_filter = compat.getsavefilename(
            parent=self, basedir=getattr(self, '_last_session_name', 'session.glu'),
            filters=("Glue Session with absolute paths to data (*.glu);; "
                     "Glue Session with relative paths to data (*.glu);; "
                     "Glue Session including data (*.glu)"),
            selectedfilter=getattr(self, '_last_session_filter',
                                   'Glue Session with relative paths to data (*.glu)'))

        # This indicates that the user cancelled
        if not outfile:
            return

        # Add extension if not specified
        if '.' not in outfile:
            outfile += '.glu'

        self._last_session_name = outfile
        self._last_session_filter = file_filter

        with set_cursor_cm(Qt.WaitCursor):
            self.save_session(outfile,
                              include_data="including data" in file_filter,
                              absolute_paths="absolute" in file_filter)

    @messagebox_on_error("Failed to restore session")
    def _restore_session(self, *args):
        """ Load a previously-saved state, and restart the session """
        fltr = "Glue sessions (*.glu)"
        file_name, file_filter = compat.getopenfilename(
            parent=self, filters=fltr)
        if not file_name:
            return

        ga = self.restore_session_and_close(file_name)
        return ga

    @property
    def is_empty(self):
        """
        Returns `True` if there are no viewers and no data.
        """
        return (len([viewer for tab in self.viewers for viewer in tab]) == 0 and
                len(self.data_collection) == 0)

    def _reset_session(self, *args, **kwargs):
        """
        Reset session to clean state.
        """

        warn = kwargs.pop('warn', False)

        if not os.environ.get('GLUE_TESTING') and warn and not self.is_empty:
            buttons = QtWidgets.QMessageBox.Ok | QtWidgets.QMessageBox.Cancel
            dialog = QtWidgets.QMessageBox.warning(
                self, "Confirm Close",
                "Are you sure you want to reset the session? "
                "This will close all datasets, subsets, and data viewers",
                buttons=buttons, defaultButton=QtWidgets.QMessageBox.Cancel)
            if not dialog == QtWidgets.QMessageBox.Ok:
                return

        # Make sure the closeEvent gets executed to close the GlueLogger
        self._log.close()
        if self.app is not None:
            self.app.processEvents()

        ga = GlueApplication()
        ga.start(block=False)

        # NOTE: we need to keep a reference to this new application object
        # otherwise it will immediately garbage collect - this is a hack and
        # we should find a better solution in future.
        self._new_application = ga

        # We need to close this after we open the next application otherwise
        # Qt will quit since there are no actively open windows.
        self.close()

        return ga

    @staticmethod
    def restore_session(path, show=True):
        """
        Reload a previously-saved session

        Parameters
        ----------
        path : str
            Path to the file to load
        show : bool, optional
            If True (the default), immediately show the widget

        Returns
        -------
        app : :class:`glue.app.qt.application.GlueApplication`
            The loaded application
        """
        ga = Application.restore_session(path)
        if show:
            ga.start(block=False)
        return ga

    def has_terminal(self, create_if_not=True):
        """
        Returns True if the IPython terminal is present.
        """
        if self._terminal is None and create_if_not:
            self._create_terminal()
        return self._terminal is not None

    def _create_terminal(self):

        if self._terminal is not None:  # already set up
            return

        try:
            widget = glue_terminal(data_collection=self._data,
                                   dc=self._data,
                                   hub=self._hub,
                                   session=self.session,
                                   application=self,
                                   **vars(env))
        except IPythonTerminalError:
            self._button_ipython.setEnabled(False)
        else:
            self._terminal = self.add_widget(widget)
            self._terminal.closed.connect(self._on_terminal_close)
            self._hide_terminal()

    def _toggle_terminal(self):
        if self._terminal is None:
            self._create_terminal()
        if self._terminal.isVisible():
            self._hide_terminal()
            if self._terminal.isVisible():
                warnings.warn("An unexpected error occurred while "
                              "trying to hide the terminal")
        else:
            self._show_terminal()
            if not self._terminal.isVisible():
                warnings.warn("An unexpected error occurred while "
                              "trying to show the terminal")

    def _on_terminal_close(self):
        if self._button_ipython.isChecked():
            self._button_ipython.blockSignals(True)
            self._button_ipython.setChecked(False)
            self._button_ipython.blockSignals(False)

    def _hide_terminal(self):
        self._terminal.hide()

    def _show_terminal(self):
        self._terminal.show()
        self._terminal.widget().show()

    def start(self, size=None, position=None, block=True, maximized=True):
        """
        Show the GUI and start the application.

        Parameters
        ----------
        size : (int, int) Optional
            The default width/height of the application.
            If not provided, uses the full screen
        position : (int, int) Optional
            The default position of the application
        """
        if maximized:
            self.showMaximized()
        else:
            self.show()
        if size is not None:
            self.resize(*size)
        if position is not None:
            self.move(*position)

        self.raise_()  # bring window to front

        # at some point during all this, the MPL backend
        # switches. This call restores things, so
        # figures are still inlined in the notebook.
        # XXX find out a better place for this
        _fix_ipython_pylab()

        if block:
            return self.app.exec_()

    exec_ = start

    def dragEnterEvent(self, event):
        if event.mimeData().hasUrls():
            event.accept()
        else:
            event.ignore()

    @messagebox_on_error("Failed to load files")
    def dropEvent(self, event):

        urls = event.mimeData().urls()

        paths = [qurl_to_path(url) for url in urls]

        if any(path.endswith('.glu') for path in paths):
            if len(paths) != 1:
                raise Exception("When dragging and dropping files onto glue, "
                                "only a single .glu session file can be "
                                "dropped at a time, or multiple datasets, but "
                                "not a mix of both.")
            else:
                self.restore_session_and_close(paths[0])
        else:
            self.load_data(paths)

        event.accept()

    @messagebox_on_error("Failed to restore session")
    def restore_session_and_close(self, path, warn=True):

        if warn and not self.is_empty:
            buttons = QtWidgets.QMessageBox.Ok | QtWidgets.QMessageBox.Cancel
            dialog = QtWidgets.QMessageBox.warning(
                self, "Confirm Close",
                "Loading a session file will close the existing session. Are you "
                "sure you want to continue?",
                buttons=buttons, defaultButton=QtWidgets.QMessageBox.Cancel)
            if not dialog == QtWidgets.QMessageBox.Ok:
                return

        with set_cursor_cm(Qt.WaitCursor):
            app = self.restore_session(path)
            app.setGeometry(self.geometry())

            # NOTE: we need to keep a reference to this new application object
            # otherwise it will immediately garbage collect - this is a hack and
            # we should find a better solution in future.
            self._new_application = app

            self.close()

    def closeEvent(self, event):
        """Emit a message to hub before closing."""

        # Clear the namespace in the terminal to avoid cicular references
        if self._terminal is not None:
            self._terminal.widget().clear_ns(['data_collection', 'dc', 'hub', 'session', 'application'])

        for tab in self.viewers:
            for viewer in tab:
                viewer.close(warn=False)
        self._viewer_in_focus = None
        self._clear_dashboard()
        self._log.close()
        self._hub.broadcast(ApplicationClosedMessage(None))
        event.accept()
        if self._original_app is not None:
            self._original_app.setWindowIcon(self._original_icon)
            self._original_app = None
        self.app = None

    def report_error(self, message, detail):
        """
        Display an error in a modal

        :param message: A short description of the error
        :type message: str
        :param detail: A longer description
        :type detail: str
        """
        qmb = QtWidgets.QMessageBox(QtWidgets.QMessageBox.Critical, "Error", message)
        qmb.setDetailedText(detail)
        qmb.resize(400, qmb.size().height())
        qmb.exec_()

    def plugin_manager(self, *args):
        from glue.main import _installed_plugins
        pm = QtPluginManager(installed=_installed_plugins)
        pm.ui.exec_()

    def _update_undo_redo_enabled(self, *args):
        undo, redo = self._cmds.can_undo_redo()
        self._actions['undo'].setEnabled(undo)
        self._actions['redo'].setEnabled(redo)
        self._actions['undo'].setText('Undo ' + self._cmds.undo_label)
        self._actions['redo'].setText('Redo ' + self._cmds.redo_label)

    @property
    def viewers(self):
        """
        A list of lists of open Data Viewers.

        Each inner list contains the viewers open on a particular tab.
        """
        result = []
        for t in range(self.tab_count):
            tab = self.tab(t)
            item = []
            for subwindow in tab.subWindowList():
                widget = subwindow.widget()
                if isinstance(widget, DataViewer):
                    item.append(widget)
            result.append(tuple(item))
        return tuple(result)

    @property
    def tab_names(self):
        """
        The name of each tab

        A list of strings
        """
        return [self.tab_bar.tabText(i) for i in range(self.tab_count)]

    @tab_names.setter
    def tab_names(self, values):
        for index, value in enumerate(values):
            self.tab_bar.setTabText(index, value)

    @staticmethod
    def _choose_merge(data, others, suggested_label):

        w = load_ui('merge.ui', None, directory=os.path.dirname(__file__))
        w.button_yes.clicked.connect(w.accept)
        w.button_no.clicked.connect(w.reject)
        w.show()
        w.raise_()

        # Add the main dataset to the list. Some of the 'others' may also be
        # new ones, so it doesn't really make sense to distinguish between
        # the two here. The main point is that some datasets, including at
        # least one new one, have a common shape.
        others.append(data)
        others.sort(key=lambda x: x.label)
        for i, d in enumerate(others):
            if isinstance(d.coords, WCSCoordinates):
                if i == 0:
                    break
                else:
                    others[0], others[i] = others[i], others[0]
                    break

        w.merged_label.setText(suggested_label)

        entries = [QtWidgets.QListWidgetItem(other.label) for other in others]
        for e in entries:
            e.setCheckState(Qt.Checked)

        for d, item in zip(others, entries):
            w.choices.addItem(item)

        if not w.exec_():
            return None, None

        result = [layer for layer, entry in zip(others, entries)
                  if entry.checkState() == Qt.Checked]

        if result:
            return result, str(w.merged_label.text())

        return None, None

    def screenshot(self, filename):
        """
        Save a screenshot of the current application window to a file.
        """
        image = QtGui.QImage(self.size(), QtGui.QImage.Format_RGB32)
        painter = QtGui.QPainter(image)
        flags = self.IgnoreMask | self.DrawWindowBackground | self.DrawChildren
        self.render(painter, QtCore.QPoint(), QtGui.QRegion(), flags)
        image.save(filename)
        painter.end()

    @classmethod
    def add_datasets(cls, data_collection, *args, **kwargs):
        result = super(GlueApplication, cls).add_datasets(data_collection, *args, **kwargs)
        run_autolinker(data_collection)
        return result

    def __gluestate__(self, context):
        state = super(GlueApplication, self).__gluestate__(context)
        state['tab_names'] = self.tab_names
        return state

    @classmethod
    def __setgluestate__(cls, rec, context):
        self = super(GlueApplication, cls).__setgluestate__(rec, context)
        if 'tab_names' in rec:
            self.tab_names = rec['tab_names']
        return self