diff --git a/qt/applications/workbench/workbench/plotting/config.py b/qt/applications/workbench/workbench/plotting/config.py index 88f416704d1b9906b94cd926fb2bf64c521a2c9e..06bf20cf73fbc8db8cef9b248efdf50b37b539ac 100644 --- a/qt/applications/workbench/workbench/plotting/config.py +++ b/qt/applications/workbench/workbench/plotting/config.py @@ -30,7 +30,8 @@ MPL_BACKEND = 'module://workbench.plotting.backend_workbench' # Our style defaults DEFAULT_RCPARAMS = { - 'figure.facecolor': 'w' + 'figure.facecolor': 'w', + 'figure.max_open_warning': 200 } diff --git a/qt/applications/workbench/workbench/plotting/figuremanager.py b/qt/applications/workbench/workbench/plotting/figuremanager.py index b31511039a157ecaa36d07f467b82b39770eaea4..21e58e85ea8947692a0da3c17021846c19b104d1 100644 --- a/qt/applications/workbench/workbench/plotting/figuremanager.py +++ b/qt/applications/workbench/workbench/plotting/figuremanager.py @@ -16,82 +16,25 @@ # along with this program. If not, see <http://www.gnu.org/licenses/>. """Provides our custom figure manager to wrap the canvas, window and our custom toolbar""" -# std imports -import sys - # 3rdparty imports import matplotlib from matplotlib.backend_bases import FigureManagerBase from matplotlib.backends.backend_qt5agg import (FigureCanvasQTAgg, backend_version, draw_if_interactive, show) # noqa from matplotlib._pylab_helpers import Gcf -from qtpy.QtCore import Qt, QEvent, QMetaObject, QObject, QThread, Signal, Slot +from qtpy.QtCore import Qt, QEvent, QObject, Signal from qtpy.QtWidgets import QApplication, QLabel, QMainWindow -from six import reraise, text_type +from six import text_type # local imports -from workbench.plotting.propertiesdialog import LabelEditor, XAxisEditor, YAxisEditor -from workbench.plotting.toolbar import WorkbenchNavigationToolbar - - -qApp = QApplication.instance() - - -class QAppThreadCall(QObject): - """ - Wraps a callable object and forces any calls made to it to be executed - on the same thread as the qApp object. - """ - - def __init__(self, callee): - QObject.__init__(self) - self.moveToThread(qApp.thread()) - self.callee = callee - # Help should then give the correct doc - self.__call__.__func__.__doc__ = callee.__doc__ - self._args = None - self._kwargs = None - self._result = None - self._exc_info = None - - def __call__(self, *args, **kwargs): - """ - If the current thread is the qApp thread then this - performs a straight call to the wrapped callable_obj. Otherwise - it invokes the do_call method as a slot via a - BlockingQueuedConnection. - """ - if QThread.currentThread() == qApp.thread(): - return self.callee(*args, **kwargs) - else: - self._store_function_args(*args, **kwargs) - QMetaObject.invokeMethod(self, "on_call", - Qt.BlockingQueuedConnection) - if self._exc_info is not None: - reraise(*self._exc_info) - return self._result - - @Slot() - def on_call(self): - """Perform a call to a GUI function across a - thread and return the result - """ - try: - self._result = \ - self.callee(*self._args, **self._kwargs) - except Exception: # pylint: disable=broad-except - self._exc_info = sys.exc_info() - - def _store_function_args(self, *args, **kwargs): - self._args = args - self._kwargs = kwargs - # Reset return value and exception - self._result = None - self._exc_info = None +from .propertiesdialog import LabelEditor, XAxisEditor, YAxisEditor +from .toolbar import WorkbenchNavigationToolbar +from .qappthreadcall import QAppThreadCall class MainWindow(QMainWindow): activated = Signal() closing = Signal() + visibility_changed = Signal() def event(self, event): if event.type() == QEvent.WindowActivate: @@ -102,6 +45,14 @@ class MainWindow(QMainWindow): self.closing.emit() QMainWindow.closeEvent(self, event) + def hideEvent(self, event): + self.visibility_changed.emit() + QMainWindow.hideEvent(self, event) + + def showEvent(self, event): + self.visibility_changed.emit() + QMainWindow.showEvent(self, event) + class FigureManagerWorkbench(FigureManagerBase, QObject): """ @@ -126,14 +77,24 @@ class FigureManagerWorkbench(FigureManagerBase, QObject): self.destroy = QAppThreadCall(self._destroy_orig) self._show_orig = self.show self.show = QAppThreadCall(self._show_orig) + self._window_activated_orig = self._window_activated + self._window_activated = QAppThreadCall(self._window_activated_orig) + self._widgetclosed_orig = self._widgetclosed + self._widgetclosed = QAppThreadCall(self._widgetclosed_orig) + self.set_window_title_orig = self.set_window_title + self.set_window_title = QAppThreadCall(self.set_window_title_orig) + self.fig_visibility_changed_orig = self.fig_visibility_changed + self.fig_visibility_changed = QAppThreadCall(self.fig_visibility_changed_orig) self.canvas = canvas self.window = MainWindow() self.window.activated.connect(self._window_activated) self.window.closing.connect(canvas.close_event) self.window.closing.connect(self._widgetclosed) + self.window.visibility_changed.connect(self.fig_visibility_changed) self.window.setWindowTitle("Figure %d" % num) + self.canvas.figure.set_label("Figure %d" % num) # Give the keyboard focus to the figure instead of the # manager; StrongFocus accepts both tab and click to focus and @@ -219,6 +180,16 @@ class FigureManagerWorkbench(FigureManagerBase, QObject): self.window.show() self.window.activateWindow() self.window.raise_() + if self.window.windowState() & Qt.WindowMinimized: + # windowState() stores a combination of window state enums + # and multiple window states can be valid. On Windows + # a window can be both minimized and maximized at the + # same time, so we make a check here. For more info see: + # http://doc.qt.io/qt-5/qt.html#WindowState-enum + if self.window.windowState() & Qt.WindowMaximized: + self.window.setWindowState(Qt.WindowMaximized) + else: + self.window.setWindowState(Qt.WindowNoState) # Hack to ensure the canvas is up to date self.canvas.draw_idle() @@ -256,6 +227,22 @@ class FigureManagerWorkbench(FigureManagerBase, QObject): def set_window_title(self, title): self.window.setWindowTitle(title) + # We need to add a call to the figure manager here to call + # notify methods when a figure is renamed, to update our + # plot list. + Gcf.figure_title_changed(self.num) + + # For the workbench we also keep the label in sync, this is + # to allow getting a handle as plt.figure('Figure Name') + self.canvas.figure.set_label(title) + + def fig_visibility_changed(self): + """ + Make a notification in the global figure manager that + plot visibility was changed. This method is added to this + class so that it can be wrapped in a QAppThreadCall. + """ + Gcf.figure_visibility_changed(self.num) # ------------------------ Interaction events -------------------- def on_button_press(self, event): diff --git a/qt/applications/workbench/workbench/plotting/functions.py b/qt/applications/workbench/workbench/plotting/functions.py index 417f47c18c00d4c031df06bf9847f27a86c381e7..9795fc0cd9737abcf20854de94033afd3900cd2d 100644 --- a/qt/applications/workbench/workbench/plotting/functions.py +++ b/qt/applications/workbench/workbench/plotting/functions.py @@ -139,9 +139,10 @@ def plot(workspaces, spectrum_nums=None, wksp_indices=None, errors=False, plot_fn(ws, **{kw: num}) ax.legend() - title = workspaces[0].name() - ax.set_title(title) - fig.canvas.set_window_title(figure_title(workspaces, fig.number)) + if not overplot: + title = workspaces[0].name() + ax.set_title(title) + fig.canvas.set_window_title(figure_title(workspaces, fig.number)) fig.canvas.draw() fig.show() return fig diff --git a/qt/applications/workbench/workbench/plotting/globalfiguremanager.py b/qt/applications/workbench/workbench/plotting/globalfiguremanager.py index 80df938f66ea13df76dfa460b423e2b1f38dfdc6..34fc66be65e1ff52087fb03b88c5fb63209a7908 100644 --- a/qt/applications/workbench/workbench/plotting/globalfiguremanager.py +++ b/qt/applications/workbench/workbench/plotting/globalfiguremanager.py @@ -23,6 +23,41 @@ import gc # 3rdparty imports import six +from mantidqt.py3compat import Enum +from .observabledictionary import DictionaryAction, ObservableDictionary + + +class FigureAction(Enum): + Unknown = 0 + New = 1 + Closed = 2 + Renamed = 3 + OrderChanged = 4 + VisibilityChanged = 5 + + +class GlobalFigureManagerObserver(object): + def notify(self, action, key): + """ + This method is called when a dictionary entry is added, + removed or changed + :param action: An enum with the type of dictionary action + :param key: The key in the dictionary that was changed + :param old_value: Old value(s) removed + """ + gcf = GlobalFigureManager + + if action == DictionaryAction.Create: + gcf.notify_observers(FigureAction.New, key) + elif action == DictionaryAction.Set: + gcf.notify_observers(FigureAction.Renamed, key) + elif action == DictionaryAction.Removed: + gcf.notify_observers(FigureAction.Closed, key) + else: + # Not expecting clear or update to be used, so we are + # being lazy here and just updating the entire plot list + gcf.notify_observers(FigureAction.Unknown, key) + class GlobalFigureManager(object): """ @@ -44,7 +79,8 @@ class GlobalFigureManager(object): """ _activeQue = [] - figs = {} + figs = ObservableDictionary({}) + figs.add_observer(GlobalFigureManagerObserver()) observers = [] @classmethod @@ -82,7 +118,7 @@ class GlobalFigureManager(object): del cls.figs[num] manager.destroy() gc.collect(1) - cls.notify_observers() + cls.notify_observers(FigureAction.OrderChanged, -1) @classmethod def destroy_fig(cls, fig): @@ -151,7 +187,7 @@ class GlobalFigureManager(object): cls._activeQue.append(m) cls._activeQue.append(manager) cls.figs[manager.num] = manager - cls.notify_observers() + cls.notify_observers(FigureAction.OrderChanged, manager.num) @classmethod def draw_all(cls, force=False): @@ -166,30 +202,22 @@ class GlobalFigureManager(object): # ------------------ Our additional interface ----------------- @classmethod - def get_figure_number_from_name(cls, figure_title): + def last_active_values(cls): """ - Returns the figure number corresponding to the figure title - passed in as a string - :param figure_title: A String containing the figure title - :return: The figure number (int) + Returns a dictionary where the keys are the plot numbers and + the values are the last shown (active) order, the most recent + being 1, the oldest being N, where N is the number of figure + managers + :return: A dictionary with the values as plot number and keys + as the opening order """ - for num, figure_manager in cls.figs.items(): - if figure_manager.get_window_title() == figure_title: - return num - return None + last_shown_order_dict = {} + num_figure_managers = len(cls._activeQue) - @classmethod - def get_figure_manager_from_name(cls, figure_title): - """ - Returns the figure manager corresponding to the figure title - passed in as a string - :param figure_title: A String containing the figure title - :return: The figure manager - """ - for figure_manager in cls.figs.values(): - if figure_manager.get_window_title() == figure_title: - return figure_manager - return None + for index in range(num_figure_managers): + last_shown_order_dict[cls._activeQue[index].num] = num_figure_managers - index + + return last_shown_order_dict # ---------------------- Observer methods --------------------- # This is currently very simple as the only observer is @@ -205,11 +233,30 @@ class GlobalFigureManager(object): cls.observers.append(observer) @classmethod - def notify_observers(cls): + def notify_observers(cls, action, figure_number): """ Calls notify method on all observers + :param action: A FigureAction enum for the action called + :param figure_number: The unique fig number (key in the dict) """ for observer in cls.observers: - observer.notify() + observer.notify(action, figure_number) + + @classmethod + def figure_title_changed(cls, figure_number): + """ + Notify the observers that a figure title was changed + :param figure_number: The unique number in GlobalFigureManager + """ + cls.notify_observers(FigureAction.Renamed, figure_number) + + @classmethod + def figure_visibility_changed(cls, figure_number): + """ + Notify the observers that a figure was shown or hidden + :param figure_number: The unique number in GlobalFigureManager + """ + cls.notify_observers(FigureAction.VisibilityChanged, figure_number) + atexit.register(GlobalFigureManager.destroy_all) diff --git a/qt/applications/workbench/workbench/plotting/observabledictionary.py b/qt/applications/workbench/workbench/plotting/observabledictionary.py new file mode 100644 index 0000000000000000000000000000000000000000..dba1c4c6e44039bda42c801cfb4bb217970aa8c3 --- /dev/null +++ b/qt/applications/workbench/workbench/plotting/observabledictionary.py @@ -0,0 +1,81 @@ +# This file is part of the mantid workbench. +# +# Copyright (C) 2017 mantidproject +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. +from mantidqt.py3compat import Enum + + +class DictionaryAction(Enum): + Create = 0 + Set = 1 + Removed = 2 + Clear = 3 + Update = 4 + + +class ObservableDictionary(dict): + """ + Override parts of dictionary that deal with adding, removing + or changing data in the dictionary to call an observer. + """ + def __init__(self, value): + super(ObservableDictionary, self).__init__(value) + self.observers = [] + + def add_observer(self, observer): + """ + Add an observer to this class - this can be any class with a + notify() method + :param observer: A class with a notify method + """ + self.observers.append(observer) + + def _notify_observers(self, action, key=-1): + for observer in self.observers: + observer.notify(action, key) + + def __setitem__(self, key, new_value): + action = DictionaryAction.Set + + if key not in self: + action = DictionaryAction.Create + dict.__setitem__(self, key, new_value) + + self._notify_observers(action, key) + + def __delitem__(self, key): + dict.__delitem__(self, key) + self._notify_observers(DictionaryAction.Removed, key) + + def clear(self): + dict.clear(self) + self._notify_observers(DictionaryAction.Clear) + + def pop(self, key, default=None): + if key in self: + old_value = dict.pop(self, key) + self._notify_observers(DictionaryAction.Removed, key) + return old_value + else: + return dict.pop(self, key, default) + + def popitem(self): + key, old_value = dict.popitem(self) + self._notify_observers(DictionaryAction.Removed, old_value=old_value) + return key, old_value + + def update(self, updated_dictionary): + dict.update(self, updated_dictionary) + self._notify_observers(DictionaryAction.Update) diff --git a/qt/applications/workbench/workbench/plotting/qappthreadcall.py b/qt/applications/workbench/workbench/plotting/qappthreadcall.py new file mode 100644 index 0000000000000000000000000000000000000000..434a94c147625018d6d50226e804bd5796741d04 --- /dev/null +++ b/qt/applications/workbench/workbench/plotting/qappthreadcall.py @@ -0,0 +1,79 @@ +# This file is part of the mantid workbench. +# +# Copyright (C) 2018 mantidproject +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. + +import sys + +from qtpy.QtCore import Qt, QMetaObject, QObject, QThread, Slot +from qtpy.QtWidgets import QApplication + +from six import reraise + + +class QAppThreadCall(QObject): + """ + Wraps a callable object and forces any calls made to it to be executed + on the same thread as the qApp object. This is required for anything + called by the matplotlib figures, which run on a separate thread. + """ + + def __init__(self, callee): + self.qApp = QApplication.instance() + + QObject.__init__(self) + self.moveToThread(self.qApp.thread()) + self.callee = callee + # Help should then give the correct doc + self.__call__.__func__.__doc__ = callee.__doc__ + self._args = None + self._kwargs = None + self._result = None + self._exc_info = None + + def __call__(self, *args, **kwargs): + """ + If the current thread is the qApp thread then this + performs a straight call to the wrapped callable_obj. Otherwise + it invokes the do_call method as a slot via a + BlockingQueuedConnection. + """ + if QThread.currentThread() == self.qApp.thread(): + return self.callee(*args, **kwargs) + else: + self._store_function_args(*args, **kwargs) + QMetaObject.invokeMethod(self, "on_call", + Qt.BlockingQueuedConnection) + if self._exc_info is not None: + reraise(*self._exc_info) + return self._result + + @Slot() + def on_call(self): + """Perform a call to a GUI function across a + thread and return the result + """ + try: + self._result = \ + self.callee(*self._args, **self._kwargs) + except Exception: # pylint: disable=broad-except + self._exc_info = sys.exc_info() + + def _store_function_args(self, *args, **kwargs): + self._args = args + self._kwargs = kwargs + # Reset return value and exception + self._result = None + self._exc_info = None diff --git a/qt/applications/workbench/workbench/plugins/plotselectorwidget.py b/qt/applications/workbench/workbench/plugins/plotselectorwidget.py index 8e4dca07527c223434bb9d604616eecd56f4dbdf..916bd0f358774ee146add72cc67bce60d52ef611 100644 --- a/qt/applications/workbench/workbench/plugins/plotselectorwidget.py +++ b/qt/applications/workbench/workbench/plugins/plotselectorwidget.py @@ -36,7 +36,7 @@ class PlotSelector(PluginWidget): plot_selector_presenter = PlotSelectorPresenter(GlobalFigureManager) # layout - self.plot_selector_widget = plot_selector_presenter.widget + self.plot_selector_widget = plot_selector_presenter.view layout = QVBoxLayout() layout.addWidget(self.plot_selector_widget) self.setLayout(layout) diff --git a/qt/applications/workbench/workbench/widgets/plotselector/model.py b/qt/applications/workbench/workbench/widgets/plotselector/model.py index b1dee4bdc338f398f265c680d8e5bcbe1a0bd525..3e6a5a829e9112ac7883938810682ffca6d4b2a2 100644 --- a/qt/applications/workbench/workbench/widgets/plotselector/model.py +++ b/qt/applications/workbench/workbench/widgets/plotselector/model.py @@ -16,6 +16,8 @@ # along with this program. If not, see <http://www.gnu.org/licenses/>. from __future__ import absolute_import, print_function +from workbench.plotting.globalfiguremanager import FigureAction + class PlotSelectorModel(object): """ @@ -32,49 +34,160 @@ class PlotSelectorModel(object): """ self.GlobalFigureManager = global_figure_manager self.presenter = presenter - self.plot_list = [] # Register with CurrentFigure that we want to know of any # changes to the list of plots self.GlobalFigureManager.add_observer(self) - def update_plot_list(self): + def get_plot_name_from_number(self, plot_number): + figure_manager = self.GlobalFigureManager.figs.get(plot_number) + if figure_manager is None: + return '' + else: + return figure_manager.get_window_title() + + # ------------------------ Plot Updates ------------------------ + + def get_plot_list(self): """ - Update the list of plots that is stored in this class, by - getting the list from the GlobalFigureManager + Returns a dictionary with the list of plots in the + GlobalFigureManager, with figure number as the keys and the + plot name as the values + :return: A dictionary with figure numbers as keys, and plot + names as values """ - self.plot_list = [] + plot_dict = {} figures = self.GlobalFigureManager.get_all_fig_managers() for figure in figures: - self.plot_list.append(figure.get_window_title()) + self.plot_dict[figure.num] = figure.get_window_title() + return plot_dict - def notify(self): + def notify(self, action, plot_number): """ This is called by GlobalFigureManager when plots are created - or destroyed. This calls the presenter to update the plot - list in the model and the view. - :return: + or destroyed, renamed or the active order is changed. This + calls the presenter to update the plot list in the model and + the view. + + IMPORTANT: Anything called here is not called from the main + GUI thread. Changes in the view must be wrapped in a + QAppThreadCall! + + :param action: A FigureAction corresponding to the event + :param plot_number: The unique number in GlobalFigureManager """ - self.presenter.update_plot_list() + if action == FigureAction.New: + self.presenter.append_to_plot_list(plot_number) + if action == FigureAction.Closed: + self.presenter.remove_from_plot_list(plot_number) + if action == FigureAction.Renamed: + figure_manager = self.GlobalFigureManager.figs.get(plot_number) + # This can be triggered before the plot is added to the + # GlobalFigureManager, so we silently ignore this case + if figure_manager is not None: + self.presenter.rename_in_plot_list(plot_number, figure_manager.get_window_title()) + if action == FigureAction.OrderChanged: + self.presenter.update_last_active_order() + if plot_number >= 0: + self.presenter.set_active_font(plot_number) + if action == FigureAction.VisibilityChanged: + self.presenter.update_visibility_icon(plot_number) + if action == FigureAction.Unknown: + self.presenter.update_plot_list() - def make_plot_active(self, plot_name): + # ------------------------ Plot Showing ------------------------ + + def show_plot(self, plot_number): """ For a given plot name make this plot active - bring it to the front and make it the destination for overplotting - :param plot_name: A string with the name of the plot - (figure title) + :param plot_number: The unique number in GlobalFigureManager + """ + figure_manager = self.GlobalFigureManager.figs.get(plot_number) + if figure_manager is None: + raise ValueError('Error showing, could not find a plot with the number {}.'.format(plot_number)) + figure_manager.show() + + # ------------------------ Plot Hiding ------------------------- + + def is_visible(self, plot_number): + """ + Determines if plot window is visible or hidden + :return: True if plot visible (window open), false if hidden + """ + figure_manager = self.GlobalFigureManager.figs.get(plot_number) + if figure_manager is None: + raise ValueError('Error in is_visible, could not find a plot with the number {}.'.format(plot_number)) + + return figure_manager.window.isVisible() + + def hide_plot(self, plot_number): + """ + Hide a plot by calling .window.hide() on the figure manager + :param plot_number: The unique number in GlobalFigureManager + """ + figure_manager = self.GlobalFigureManager.figs.get(plot_number) + if figure_manager is None: + raise ValueError('Error hiding, could not find a plot with the number {}.'.format(plot_number)) + figure_manager.window.hide() + + # ------------------------ Plot Renaming ------------------------ + + def rename_figure(self, plot_number, new_name): """ - figure_manager = self.GlobalFigureManager.get_figure_manager_from_name(plot_name) - if figure_manager is not None: - figure_manager.show() + Renames a figure in the GlobalFigureManager + :param plot_number: The unique number in GlobalFigureManager + :param new_name: The new figure (plot) name + """ + figure_manager = self.GlobalFigureManager.figs.get(plot_number) + if figure_manager is None: + raise ValueError('Error renaming, could not find a plot with the number {}.'.format(plot_number)) + + figure_manager.set_window_title(new_name) - def close_plot(self, plot_name): + # ------------------------ Plot Closing ------------------------- + + def close_plot(self, plot_number): """ For a given plot close and remove all reference in the GlobalFigureManager - :param plot_name: A string with the name of the plot - (figure title) + :param plot_number: The unique number in GlobalFigureManager + """ + figure_manager = self.GlobalFigureManager.figs.get(plot_number) + if figure_manager is None: + raise ValueError('Error closing, could not find a plot with the number {}.'.format(plot_number)) + + self.GlobalFigureManager.destroy(plot_number) + + # ----------------------- Plot Sorting -------------------------- + + def last_active_values(self): + """ + Returns a dictionary containing the order of the last shown + plots. Not all plots are guaranteed to be in returned + dictionary. + :return: A dictionary containing the plot numbers as keys, + and the order (1...N) as values + (e.g. {1: 2, 2: 1, 7: 3}) + """ + return self.GlobalFigureManager.last_active_values() + + # ---------------------- Plot Exporting ------------------------- + + def export_plot(self, plot_number, save_absolute_path): + """ + Export a plot, with the type based on the the filename + extension + :param plot_number: The unique number in GlobalFigureManager + :param save_absolute_path: The absolute path, with the + extension giving the type + :return: """ - figure_number_to_close = self.GlobalFigureManager.get_figure_number_from_name(plot_name) - if figure_number_to_close is not None: - self.GlobalFigureManager.destroy(figure_number_to_close) + figure_manager = self.GlobalFigureManager.figs.get(plot_number) + if figure_manager is None: + raise ValueError('Error exporting, could not find a plot with the number {}.'.format(plot_number)) + + try: + figure_manager.canvas.figure.savefig(save_absolute_path) + except IOError as e: + raise ValueError("Error, could not save plot to {}.\n\nError was: {}".format(save_absolute_path, e)) diff --git a/qt/applications/workbench/workbench/widgets/plotselector/presenter.py b/qt/applications/workbench/workbench/widgets/plotselector/presenter.py index 09da4e8b11a316a601fffbafa2b4e9cbb913337b..8d80535928d1818566f6dee9b8c1b3903700adba 100644 --- a/qt/applications/workbench/workbench/widgets/plotselector/presenter.py +++ b/qt/applications/workbench/workbench/widgets/plotselector/presenter.py @@ -16,8 +16,11 @@ # along with this program. If not, see <http://www.gnu.org/licenses/>. from __future__ import absolute_import, print_function +import os +import re + from .model import PlotSelectorModel -from .view import PlotSelectorView +from .view import PlotSelectorView, Column class PlotSelectorPresenter(object): @@ -39,9 +42,9 @@ class PlotSelectorPresenter(object): """ # Create model and view, or accept mocked versions if view is None: - self.widget = PlotSelectorView(self) + self.view = PlotSelectorView(self) else: - self.widget = view + self.view = view if model is None: self.model = PlotSelectorModel(self, global_figure_manager) else: @@ -50,17 +53,169 @@ class PlotSelectorPresenter(object): # Make sure the plot list is up to date self.update_plot_list() + def get_plot_name_from_number(self, plot_number): + return self.model.get_plot_name_from_number(plot_number) + + # ------------------------ Plot Updates ------------------------ + def update_plot_list(self): """ Updates the plot list in the model and the view. Filter text is applied to the updated selection if required. """ - self.model.update_plot_list() - filter_text = self.widget.get_filter_text() - if not filter_text: - self.widget.set_plot_list(self.model.plot_list) + plot_list = self.model.get_plot_list() + self.view.set_plot_list(plot_list) + + def append_to_plot_list(self, plot_number): + """ + Appends the plot name to the end of the plot list + :param plot_number: The unique number in GlobalFigureManager + """ + self.view.append_to_plot_list(plot_number) + self.view.set_visibility_icon(plot_number, self.model.is_visible(plot_number)) + + def remove_from_plot_list(self, plot_number): + """ + Removes the plot name from the plot list + :param plot_number: The unique number in GlobalFigureManager + """ + self.view.remove_from_plot_list(plot_number) + + def rename_in_plot_list(self, plot_number, new_name): + """ + Replaces a name in the plot list + :param plot_number: The unique number in GlobalFigureManager + :param new_name: The new name for the plot + """ + self.view.rename_in_plot_list(plot_number, new_name) + + # ----------------------- Plot Filtering ------------------------ + + def filter_text_changed(self): + """ + Called by the view when the filter text is changed (e.g. by + typing or clearing the text) + """ + if self.view.get_filter_text(): + self.view.filter_plot_list() + else: + self.view.unhide_all_plots() + + def is_shown_by_filter(self, plot_number): + """ + :param plot_number: The unique number in GlobalFigureManager + :return: True if shown, or False if filtered out + """ + filter_text = self.view.get_filter_text() + plot_name = self.get_plot_name_from_number(plot_number) + return filter_text.lower() in plot_name.lower() + + # ------------------------ Plot Showing ------------------------ + + def show_single_selected(self): + """ + When a list item is double clicked the view calls this method + to bring the selected plot to the front + """ + plot_number = self.view.get_currently_selected_plot_number() + self._make_plot_active(plot_number) + + def show_multiple_selected(self): + """ + Shows multiple selected plots, e.g. from pressing the 'Show' + button with multiple selected plots + """ + selected_plots = self.view.get_all_selected_plot_numbers() + for plot_number in selected_plots: + self._make_plot_active(plot_number) + + def _make_plot_active(self, plot_number): + """ + Make the plot with the given name active - bring it to the + front and make it the choice for overplotting + :param plot_number: The unique number in GlobalFigureManager + """ + try: + self.model.show_plot(plot_number) + except ValueError as e: + print(e) + + def set_active_font(self, plot_number): + """ + Set the icon for the active plot to be colored + :param plot_number: The unique number in GlobalFigureManager + """ + active_plot_number = self.view.active_plot_number + if active_plot_number > 0: + try: + self.view.set_active_font(active_plot_number, False) + except TypeError: + pass + # The last active plot could have been closed + # already, so there is nothing to do + self.view.set_active_font(plot_number, True) + self.view.active_plot_number = plot_number + + # ------------------------ Plot Hiding ------------------------- + + def hide_selected_plots(self): + """ + Hide all plots that are selected in the view + """ + selected_plots = self.view.get_all_selected_plot_numbers() + + for plot_number in selected_plots: + self._hide_plot(plot_number) + + def _hide_plot(self, plot_number): + """ + Hides a single plot + """ + try: + self.model.hide_plot(plot_number) + except ValueError as e: + print(e) + + def toggle_plot_visibility(self, plot_number): + """ + Toggles a plot between hidden and shown + :param plot_number: The unique number in GlobalFigureManager + """ + if self.model.is_visible(plot_number): + self._hide_plot(plot_number) else: - self._filter_plot_list_by_string(filter_text) + self._make_plot_active(plot_number) + + self.update_visibility_icon(plot_number) + + def update_visibility_icon(self, plot_number): + """ + Updates the icon to indicate a plot as hidden or visible + :param plot_number: The unique number in GlobalFigureManager + """ + try: + is_visible = self.model.is_visible(plot_number) + self.view.set_visibility_icon(plot_number, is_visible) + except ValueError: + # There is a chance the plot was closed, which calls an + # update to this method. If we can not get the visibility + # status it is safe to assume the plot has been closed. + pass + + # ------------------------ Plot Renaming ------------------------ + + def rename_figure(self, plot_number, new_name): + """ + Replaces a name in the plot list + :param plot_number: The unique number in GlobalFigureManager + :param new_name: The new plot name + """ + try: + self.model.rename_figure(plot_number, new_name) + except ValueError as e: + # We need to undo the rename in the view + self.view.rename_in_plot_list(plot_number, new_name) + print(e) # ------------------------ Plot Closing ------------------------- @@ -69,48 +224,189 @@ class PlotSelectorPresenter(object): This is called by the view when closing plots is requested (e.g. pressing close or delete). """ - selected_plots = self.widget.get_all_selected_plot_names() + selected_plots = self.view.get_all_selected_plot_numbers() self._close_plots(selected_plots) - def _close_plots(self, list_of_plots): + def close_single_plot(self, plot_number): + """ + This is used to close plots when a close action is called + that does not refer to the selected plot(s) + :param plot_number: The unique number in GlobalFigureManager + """ + self._close_plots([plot_number]) + + def _close_plots(self, list_of_plot_numbers): """ Accepts a list of plot names to close :param list_of_plots: A list of strings containing plot names """ - for plot_name in list_of_plots: - self.model.close_plot(plot_name) + for plot_number in list_of_plot_numbers: + try: + self.model.close_plot(plot_number) + except ValueError as e: + print(e) - # ----------------------- Plot Filtering ------------------------ + # ----------------------- Plot Sorting -------------------------- - def filter_text_changed(self): + def set_sort_order(self, is_ascending): """ - Called by the view when the filter text is changed (e.g. by - typing or clearing the text) + Sets the sort order in the view + :param is_ascending: If true ascending order, else descending + """ + self.view.set_sort_order(is_ascending) + + def set_sort_type(self, sort_type): + """ + Sets the sort order in the view + :param sort_type: A Column enum with the column to sort on + """ + self.view.set_sort_type(sort_type) + self.update_last_active_order() + + def update_last_active_order(self): + """ + Update the sort keys in the view. This is only required when + changes to the last shown order occur in the model, when + renaming the key is set already + """ + if self.view.sort_type() == Column.LastActive: + self._set_last_active_order() + + def _set_last_active_order(self): + """ + Set the last shown order in the view. This checks the sorting + currently set and then sets the sort keys to the appropriate + values + """ + last_active_values = self.model.last_active_values() + self.view.set_last_active_values(last_active_values) + + def get_initial_last_active_value(self, plot_number): """ - filter_text = self.widget.get_filter_text() - self._filter_plot_list_by_string(filter_text) + Gets the initial last active value for a plot just added, in + this case it is assumed to not have been shown + :param plot_number: The unique number in GlobalFigureManager + :return: A string with the last active value + """ + return '_' + self.model.get_plot_name_from_number(plot_number) - def _filter_plot_list_by_string(self, filter_text): + def get_renamed_last_active_value(self, plot_number, old_last_active_value): """ - Given a string to filter on this updates the list of plots - in the view or shows all plots if the string is empty - :param filter_text: A string containing the filter text + Gets the initial last active value for a plot that was + renamed. If the plot had a numeric value, i.e. has been shown + this is retained, else it is set + :param plot_number: The unique number in GlobalFigureManager + :param old_last_active_value: The previous last active value """ - if not filter_text: - self.widget.set_plot_list(self.model.plot_list) + if old_last_active_value.isdigit(): + return old_last_active_value else: - filtered_plot_list = [] - for plot_name in self.model.plot_list: - if filter_text.lower() in plot_name.lower(): - filtered_plot_list.append(plot_name) - self.widget.set_plot_list(filtered_plot_list) + return self.get_initial_last_active_value(plot_number) - # ----------------------- Plot Selection ------------------------ + # ---------------------- Plot Exporting ------------------------- - def list_double_clicked(self): + def export_plots_called(self, extension): """ - When a list item is double clicked the view calls this method - to bring the selected plot to the front + Export plots called from the view, then a single or multiple + plots exported depending on the number currently selected + :param extension: The file extension as a string including + a '.', for example '.png' (must be a type + supported by matplotlib) + """ + plot_numbers = self.view.get_all_selected_plot_numbers() + + if len(plot_numbers) == 1: + self._export_single_plot(plot_numbers[0], extension) + elif len(plot_numbers) > 1: + self._export_multiple_plots(plot_numbers, extension) + + def _export_single_plot(self, plot_number, extension): + """ + Called when a single plot is selected to export - prompts for + a filename then tries to save the plot + :param plot_number: The unique number in GlobalFigureManager + :param extension: The file extension as a string including + a '.', for example '.png' (must be a type + supported by matplotlib) + """ + absolute_path = self.view.get_file_name_for_saving(extension) + + if not absolute_path[-4:] == extension: + absolute_path += extension + + try: + self.model.export_plot(plot_number, absolute_path) + except ValueError as e: + print(e) + + def _export_multiple_plots(self, plot_numbers, extension): + """ + Export all selected plots in the plot_numbers list, first + prompting for a save directory then sanitising plot names to + unique, usable file names + :param plot_numbers: A list of plot numbers to export + :param extension: The file extension as a string including + a '.', for example '.png' (must be a type + supported by matplotlib) + """ + dir_name = self.view.get_directory_name_for_saving() + + # A temporary dictionary holding plot numbers as keys, plot + # names as values + plots = {} + + for plot_number in plot_numbers: + plot_name = self.model.get_plot_name_from_number(plot_number) + plot_name = self._replace_special_characters(plot_name) + if plot_name in plots.values(): + plot_name = self._make_unique_name(plot_name, plots) + plots[plot_number] = plot_name + + self._export_plot(plot_number, plot_name, dir_name, extension) + + def _replace_special_characters(self, string): + """ + Removes any characters that are not valid in file names + across all operating systems ('/' for Linux/Mac), more for + Windows + :param string: The string to replace characters in + :return: The string with special characters replace by '-' + """ + return re.sub(r'[<>:"/|\\?*]', r'-', string) + + def _make_unique_name(self, name, dictionary): + """ + Given a name and a dictionary, make a unique name that does + not already exist in the dictionary values by appending + ' (1)', ' (2)', ' (3)' etc. to the end of the name + :param name: A string with the non-unique name + :param dictionary: A dictionary with string values + :return : The unique plot name + """ + i = 1 + while True: + plot_name_attempt = name + ' ({})'.format(str(i)) + if plot_name_attempt not in dictionary.values(): + break + i += 1 + + return plot_name_attempt + + def _export_plot(self, plot_number, plot_name, dir_name, extension): + """ + Given a plot number, plot name, directory and extension + construct the absolute path name and call the model to save + the figure + :param plot_number: The unique number in GlobalFigureManager + :param plot_name: The name to use for saving + :param dir_name: The directory to save to + :param extension: The file extension as a string including + a '.', for example '.png' (must be a type + supported by matplotlib) """ - plot_name = self.widget.get_currently_selected_plot_name() - self.model.make_plot_active(plot_name) + if dir_name: + filename = os.path.join(dir_name, plot_name + extension) + try: + self.model.export_plot(plot_number, filename) + except ValueError as e: + print(e) diff --git a/qt/applications/workbench/workbench/widgets/plotselector/test/test_plotselector_model.py b/qt/applications/workbench/workbench/widgets/plotselector/test/test_plotselector_model.py index 90eb001552edb327d94a50ec77209b16d1453a47..75e2d1221a4f5a596c7a78dc59786710f426b256 100644 --- a/qt/applications/workbench/workbench/widgets/plotselector/test/test_plotselector_model.py +++ b/qt/applications/workbench/workbench/widgets/plotselector/test/test_plotselector_model.py @@ -16,6 +16,8 @@ # along with this program. If not, see <http://www.gnu.org/licenses/>. from __future__ import absolute_import, division, print_function +from workbench.plotting.globalfiguremanager import FigureAction + from workbench.widgets.plotselector.model import PlotSelectorModel from workbench.widgets.plotselector.presenter import PlotSelectorPresenter @@ -28,56 +30,128 @@ except ImportError: class PlotSelectorModelTest(unittest.TestCase): - def side_effects_manager(self, plot_name): - if plot_name == "Plot1": + def side_effect_manager(self, plot_number): + if plot_number == 42: return self.figure_manager return None - def side_effects_number(self, plot_name): - if plot_name == "Plot1": - return 42 - return None - def setUp(self): self.presenter = mock.Mock(spec=PlotSelectorPresenter) self.figure_manager = mock.Mock() self.figure_manager.show = mock.Mock() + self.figure_manager.get_window_title = mock.Mock(return_value="Plot1") + self.figure_manager.is_visible = mock.Mock(return_value=True) self.global_figure_manager = mock.Mock() self.global_figure_manager.add_observer = mock.Mock() - self.global_figure_manager.get_figure_manager_from_name = mock.Mock(side_effect=self.side_effects_manager) - self.global_figure_manager.get_figure_number_from_name = mock.Mock(side_effect=self.side_effects_number) + self.global_figure_manager.figs.get = mock.Mock(side_effect=self.side_effect_manager) self.global_figure_manager.destroy = mock.Mock() self.model = PlotSelectorModel(self.presenter, self.global_figure_manager) self.model.plot_list = ["Plot1", "Plot2"] + # ------------------------ Plot Updates ------------------------ + def test_observer_added_during_setup(self): self.assertEqual(self.global_figure_manager.add_observer.call_count, 1) - def test_notify_calls_update_in_presenter(self): - self.model.notify() - self.assertEqual(self.presenter.update_plot_list.call_count, 1) - self.model.notify() - self.assertEqual(self.presenter.update_plot_list.call_count, 2) + def test_notify_for_new_plot_calls_append_in_presenter(self): + self.model.notify(FigureAction.New, 42) + self.presenter.append_to_plot_list.assert_called_once_with(42) + + def test_notify_for_closed_plot_calls_removed_in_presenter(self): + self.model.notify(FigureAction.Closed, 42) + self.presenter.remove_from_plot_list.assert_called_once_with(42) + + def test_notify_for_renamed_plot_calls_rename_in_presenter(self): + self.model.notify(FigureAction.Renamed, 42) + self.presenter.rename_in_plot_list.assert_called_once_with(42, "Plot1") + + def test_notify_for_renamed_plot_with_invalid_figure_number_does_nothing(self): + self.model.notify(FigureAction.Renamed, 0) + self.presenter.rename_in_plot_list.assert_not_called() - def test_make_plot_active_calls_current_figure(self): - self.model.make_plot_active("Plot1") + def test_notify_for_order_changed_calls_presenter(self): + self.model.notify(FigureAction.OrderChanged, 42) + self.presenter.update_last_active_order.assert_called_once_with() + self.presenter.set_active_font.assert_called_once_with(42) + + def test_notify_for_order_changed_with_invalid_plot_number(self): + self.model.notify(FigureAction.OrderChanged, -1) + self.presenter.update_last_active_order.assert_called_once_with() + self.presenter.set_active_font.assert_not_called() + + def test_notify_visibility_changed_calls_presesnter(self): + self.model.notify(FigureAction.VisibilityChanged, 42) + self.presenter.update_visibility_icon.assert_called_once_with(42) + + def test_notify_unknwon_updates_plot_list_in_presenter(self): + self.model.notify(FigureAction.Unknown, -1) + self.presenter.update_plot_list.assert_called_once_with() + + # ------------------------ Plot Showing ------------------------ + + def test_show_plot_calls_current_figure(self): + self.model.show_plot(42) self.assertEqual(self.figure_manager.show.call_count, 1) - def test_make_plot_active_for_invalid_name_does_nothing(self): - self.model.make_plot_active("NotAPlot") + def test_show_plot_for_invalid_name_raises_value_error(self): + self.assertRaises(ValueError, self.model.show_plot, 0) self.figure_manager.show.assert_not_called() - def test_close_plot_calls_destroy_in_current_figure(self): - self.model.close_plot("Plot1") - self.global_figure_manager.destroy.assert_called_once_with(42) + # ------------------------ Plot Hiding ------------------------- + + def test_is_visible_returns_true_for_visible_window(self): + self.assertTrue(self.model.is_visible(42)) - def test_close_plot_for_invalid_name_does_noting(self): - self.model.close_plot("NotAPlot") + def test_is_visible_for_invalid_number_raises_value_error(self): + self.assertRaises(ValueError, self.model.is_visible, 0) + + def test_hide_plot_calls_hide_on_the_plot_window(self): + self.model.hide_plot(42) + self.figure_manager.window.hide.assert_called_once_with() + + def test_hide_plot_for_invalid_name_raises_value_error(self): + self.assertRaises(ValueError, self.model.hide_plot, 0) + self.figure_manager.window.hide.asser_not_called() + + # ------------------------ Plot Renaming ------------------------ + + def test_renaming_calls_set_window_title(self): + self.model.rename_figure(42, "NewName") + self.figure_manager.set_window_title.assert_called_once_with("NewName") + + def test_renaming_calls_with_invalid_number_raises_value_error(self): + self.assertRaises(ValueError, self.model.rename_figure, 0, "NewName") + self.figure_manager.set_window_title.assert_not_called() + + # ------------------------ Plot Closing ------------------------- + + def test_notify_for_closing_plot_calls_remove_in_presenter(self): + self.model.notify(FigureAction.Closed, "Plot1") + self.presenter.remove_from_plot_list.assert_called_once_with("Plot1") + + def test_close_plot_for_invalid_number_raises_value_error(self): + self.assertRaises(ValueError, self.model.close_plot, 0) self.global_figure_manager.destroy.assert_not_called() + def test_close_plot_calls_destroy_in_global_figure_manager(self): + self.model.close_plot(42) + self.global_figure_manager.destroy.assert_called_once_with(42) + + # ----------------------- Plot Sorting -------------------------- + + def test_last_active_values_calls_global_figure_manager(self): + self.model.last_active_values() + self.global_figure_manager.last_active_values.assert_called_once_with() + + # ---------------------- Plot Exporting ------------------------- + + def test_export_plot_calls_savefig_on_figure(self): + self.model.export_plot(42, "/home/Documents/Figure1.pdf") + self.figure_manager.canvas.figure.savefig.assert_called_once_with("/home/Documents/Figure1.pdf") + if __name__ == '__main__': unittest.main() diff --git a/qt/applications/workbench/workbench/widgets/plotselector/test/test_plotselector_presenter.py b/qt/applications/workbench/workbench/widgets/plotselector/test/test_plotselector_presenter.py index aaa41092d18f0311945e2e60580e1a8e46f3c0b9..6c8f161b84ea4c8a51cc87d9897b882b4d1a6179 100644 --- a/qt/applications/workbench/workbench/widgets/plotselector/test/test_plotselector_presenter.py +++ b/qt/applications/workbench/workbench/widgets/plotselector/test/test_plotselector_presenter.py @@ -18,7 +18,9 @@ from __future__ import absolute_import, division, print_function from workbench.widgets.plotselector.model import PlotSelectorModel from workbench.widgets.plotselector.presenter import PlotSelectorPresenter -from workbench.widgets.plotselector.view import PlotSelectorView +from workbench.widgets.plotselector.view import PlotSelectorView, Column + +import os import unittest try: @@ -29,19 +31,31 @@ except ImportError: class PlotSelectorPresenterTest(unittest.TestCase): + def side_effect_plot_name(self, plot_number): + if plot_number in [0, 101, 102, 103]: + return "Plot1" + if plot_number == 1: + return "Plot2" + if plot_number == 2: + return "Plot3" + if plot_number == 42: + return "Graph99" + return None + def setUp(self): - self.widget = mock.Mock(spec=PlotSelectorView) - self.widget.get_filter_text = mock.Mock(return_value="") + self.view = mock.Mock(spec=PlotSelectorView) + self.view.get_filter_text = mock.Mock(return_value="") self.model = mock.Mock(spec=PlotSelectorModel) - self.model.configure_mock(plot_list=["Plot1", "Plot2", "Plot3", "Graph99"]) + self.model.get_plot_list = mock.Mock(return_value=[0, 1, 2, 42]) + self.model.get_plot_name_from_number = mock.Mock(side_effect=self.side_effect_plot_name) - self.presenter = PlotSelectorPresenter(None, self.widget, self.model) - self.presenter.widget = self.widget + self.presenter = PlotSelectorPresenter(None, self.view, self.model) + self.presenter.widget = self.view self.presenter.model = self.model # Ignore calls during the setup - self.widget.reset_mock() + self.view.reset_mock() self.model.reset_mock() def convert_list_to_calls(self, list_to_convert): @@ -50,77 +64,244 @@ class PlotSelectorPresenterTest(unittest.TestCase): call_list.append(mock.call(item)) return call_list + # ------------------------ Plot Updates ------------------------ + def test_plot_list_update(self): self.presenter.update_plot_list() - self.assertEqual(self.model.update_plot_list.call_count, 1) - self.widget.set_plot_list.assert_called_once_with(["Plot1", "Plot2", "Plot3", "Graph99"]) + self.assertEqual(self.model.get_plot_list.call_count, 1) + self.view.set_plot_list.assert_called_once_with([0, 1, 2, 42]) - def test_plot_list_update_with_filter_set(self): - self.widget.get_filter_text = mock.Mock(return_value="Graph99") - self.presenter.update_plot_list() - self.assertEqual(self.model.update_plot_list.call_count, 1) - self.widget.set_plot_list.assert_called_once_with(["Graph99"]) + def test_append_to_plot_list_calls_update_in_model_and_view(self): + self.presenter.append_to_plot_list(42) + self.view.append_to_plot_list.assert_called_once_with(42) + + def test_remove_from_plot_list_calls_update_in_model_and_view(self): + self.presenter.remove_from_plot_list(42) + self.view.remove_from_plot_list.assert_called_once_with(42) + + def test_rename_in_plot_list_calls_update_in_model_and_view(self): + self.presenter.rename_in_plot_list(42, "NewName") + self.view.rename_in_plot_list.assert_called_once_with(42, "NewName") + + # ----------------------- Plot Filtering ------------------------ + + def test_no_filtering_displays_all_plots(self): + self.presenter.filter_text_changed() + self.view.unhide_all_plots.assert_called_once_with() + + def filtering_calls_filter_on_view(self): + self.view.get_filter_text = mock.Mock(return_value="Plot1") + + self.presenter.filter_text_changed() + self.view.filter_plot_list.assert_called_once_with("Plot1") + + def test_plots_filtered_on_full_name(self): + self.view.get_filter_text = mock.Mock(return_value="Plot1") + self.assertTrue(self.presenter.is_shown_by_filter(0)) # Plot1 + self.assertFalse(self.presenter.is_shown_by_filter(1)) # Plot 2 + + def test_plots_filtered_on_substring(self): + self.view.get_filter_text = mock.Mock(return_value="lot") + self.assertTrue(self.presenter.is_shown_by_filter(0)) # Plot1 + self.assertFalse(self.presenter.is_shown_by_filter(42)) # Graph99 + + def test_filtering_case_invariant(self): + self.view.get_filter_text = mock.Mock(return_value="pLOT1") + self.assertTrue(self.presenter.is_shown_by_filter(0)) # Plot1 + self.assertFalse(self.presenter.is_shown_by_filter(1)) # Plot2 + + # ------------------------ Plot Showing ------------------------ + + def test_show_single_plot_shows_it(self): + self.view.get_currently_selected_plot_number = mock.Mock(return_value=1) + self.presenter.show_single_selected() + self.model.show_plot.assert_called_once_with(1) + + def test_show_multiple_plots_shows_them(self): + self.view.get_all_selected_plot_numbers = mock.Mock(return_value=[1, 2]) + self.presenter.show_multiple_selected() + self.assertEqual(self.model.show_plot.mock_calls[0], mock.call(1)) + self.assertEqual(self.model.show_plot.mock_calls[1], mock.call(2)) + + def test_set_active_font_sets_active_font_in_view(self): + self.view.active_plot_number = 1 + self.presenter.set_active_font(2) + # 2 calls, one to set the plot number 1 to normal, one to + # set plot number 2 to bold + self.assertEqual(self.view.set_active_font.mock_calls[0], mock.call(1, False)) + self.assertEqual(self.view.set_active_font.mock_calls[1], mock.call(2, True)) + self.assertEqual(self.view.active_plot_number, 2) + + # ------------------------ Plot Hiding ------------------------- + + def test_hide_multiple_plots_calls_hide_in_model(self): + self.view.get_all_selected_plot_numbers = mock.Mock(return_value=[1, 2]) + self.presenter.hide_selected_plots() + self.assertEquals(self.model.hide_plot.mock_calls[0], mock.call(1)) + self.assertEquals(self.model.hide_plot.mock_calls[1], mock.call(2)) + + def test_toggle_plot_visibility_for_visible_plot(self): + self.model.is_visible = mock.Mock(return_value=True) + self.presenter.toggle_plot_visibility(42) + self.model.hide_plot.assert_called_once_with(42) + self.view.set_visibility_icon.assert_called_once_with(42, True) + + def test_toggle_plot_visibility_for_hidden_plot(self): + self.model.is_visible = mock.Mock(return_value=False) + self.presenter.toggle_plot_visibility(42) + self.model.show_plot.assert_called_once_with(42) + self.view.set_visibility_icon.assert_called_once_with(42, False) + + # ------------------------ Plot Renaming ------------------------ + + def test_rename_figure_calls_rename_in_model(self): + self.presenter.rename_figure(0, "NewName") + self.model.rename_figure.assert_called_once_with(0, "NewName") + + def test_rename_figure_raising_a_value_error_undoes_rename_in_view(self): + self.model.rename_figure.side_effect = ValueError("Some problem") + self.presenter.rename_figure(0, "NewName") + self.model.rename_figure.assert_called_once_with(0, "NewName") + self.view.rename_in_plot_list.assert_called_once_with(0, "NewName") # ------------------------ Plot Closing ------------------------- def test_close_action_single_plot(self): - self.widget.get_all_selected_plot_names = mock.Mock(return_value=["Plot1"]) + self.view.get_all_selected_plot_numbers = mock.Mock(return_value=[42]) self.presenter.close_action_called() - self.model.close_plot.assert_called_once_with("Plot1") + self.model.close_plot.assert_called_once_with(42) def test_close_action_multiple_plots(self): - self.widget.get_all_selected_plot_names = mock.Mock(return_value=["Plot1", "Plot3"]) + self.view.get_all_selected_plot_numbers = mock.Mock(return_value=[42, 43]) self.presenter.close_action_called() self.assertEqual(self.model.close_plot.call_count, 2) - self.model.close_plot.assert_has_calls(self.convert_list_to_calls(["Plot1", "Plot3"]), + self.model.close_plot.assert_has_calls(self.convert_list_to_calls([42, 43]), any_order=True) + def test_close_action_with_model_call_raising_value_error(self): + self.view.get_all_selected_plot_numbers = mock.Mock(return_value=[42]) + self.model.close_plot.side_effect = ValueError("Some problem") + + self.presenter.close_action_called() + self.model.close_plot.assert_called_once_with(42) + def test_close_action_with_no_plots_open(self): - self.widget.get_all_selected_plot_names = mock.Mock(return_value=[]) + self.view.get_all_selected_plot_numbers = mock.Mock(return_value=[]) self.model.configure_mock(plot_list=[]) self.presenter.close_action_called() self.assertEqual(self.model.close_plot.call_count, 0) def test_close_action_with_no_plots_selected(self): - self.widget.get_all_selected_plot_names = mock.Mock(return_value=[]) + self.view.get_all_selected_plot_numbers = mock.Mock(return_value=[]) self.presenter.close_action_called() self.assertEqual(self.model.close_plot.call_count, 0) - # ----------------------- Plot Filtering ------------------------ - - def test_no_filtering_displays_all_plots(self): - self.presenter.filter_text_changed() - self.widget.set_plot_list.assert_called_once_with(["Plot1", "Plot2", "Plot3", "Graph99"]) - - def test_plots_filtered_on_full_name(self): - self.widget.get_filter_text = mock.Mock(return_value="Plot1") - self.presenter.filter_text_changed() - - self.widget.set_plot_list.assert_called_once_with(["Plot1"]) - - def test_plots_filtered_on_substring(self): - self.widget.get_filter_text = mock.Mock(return_value="lot") - self.presenter.filter_text_changed() - - self.widget.set_plot_list.assert_called_once_with(["Plot1", "Plot2", "Plot3"]) - - def test_filtering_case_invariant(self): - self.widget.get_filter_text = mock.Mock(return_value="pLOT1") - self.presenter.filter_text_changed() - - self.widget.set_plot_list.assert_called_once_with(["Plot1"]) - - # ----------------------- Plot Selection ------------------------ - - def test_double_clicking_plot_brings_to_front(self): - self.widget.get_currently_selected_plot_name = mock.Mock(return_value="Plot2") - self.presenter.list_double_clicked() - - self.model.make_plot_active.assert_called_once_with("Plot2") + def test_close_single_plot_called(self): + self.presenter.close_single_plot("Plot2") + self.model.close_plot.assert_called_once_with("Plot2") + + # ----------------------- Plot Sorting -------------------------- + + def test_set_sort_order_to_ascending_calls_view_update(self): + self.presenter.set_sort_order(is_ascending=True) + self.view.set_sort_order.assert_called_once_with(True) + + def test_set_sort_order_to_descending_calls_view_update(self): + self.presenter.set_sort_order(is_ascending=False) + self.view.set_sort_order.assert_called_once_with(False) + + def test_set_sort_type_to_name(self): + self.view.sort_type = mock.Mock(return_value=Column.Name) + self.presenter.set_sort_type(Column.Name) + self.view.set_sort_type.assert_called_once_with(Column.Name) + self.model.last_active_values.assert_not_called() + self.view.set_last_active_values.assert_not_called() + + def test_set_sort_type_to_last_active(self): + self.model.last_active_values = mock.Mock(return_value={0: 1, 1: 2}) + self.view.sort_type = mock.Mock(return_value=Column.LastActive) + self.presenter.set_sort_type(Column.LastActive) + + self.view.set_sort_type.assert_called_once_with(Column.LastActive) + self.view.set_last_active_values.assert_called_once_with({0: 1, + 1: 2}) + + def test_set_last_active_values_with_sorting_by_last_active(self): + self.model.last_active_values = mock.Mock(return_value={0: 1, 1: 2}) + self.view.sort_type = mock.Mock(return_value=Column.LastActive) + self.presenter.update_last_active_order() + + self.view.set_last_active_values.assert_called_once_with({0: 1, + 1: 2}) + + def test_set_last_active_values_with_sorting_by_name_does_nothing(self): + self.model.last_active_order = mock.Mock(return_value={0: 1, 1: 2}) + self.view.sort_type = mock.Mock(return_value=Column.Name) + self.presenter.update_last_active_order() + + self.model.last_active_order.assert_not_called() + self.view.set_last_active_values.assert_not_called() + + def test_get_initial_last_active_value(self): + self.assertEqual(self.presenter.get_initial_last_active_value(0), "_Plot1") + + def test_get_renamed_last_active_value_for_numeric_old_value(self): + self.assertEqual(self.presenter.get_renamed_last_active_value(1, "23"), "23") + + def test_get_renamed_last_active_value_for_never_shown_value(self): + self.assertEqual(self.presenter.get_renamed_last_active_value(0, "_Plot1"), "_Plot1") + + # ---------------------- Plot Exporting ------------------------- + + def test_exporting_single_plot_generates_correct_filename(self): + self.view.get_all_selected_plot_numbers = mock.Mock(return_value=[0]) + self.view.get_file_name_for_saving = mock.Mock(return_value='/home/Documents/Plot1') + self.presenter.export_plots_called('.xyz') + self.model.export_plot.assert_called_once_with(0, '/home/Documents/Plot1.xyz') + + def test_exporting_single_plot_with_extension_given_in_file_name(self): + self.view.get_all_selected_plot_numbers = mock.Mock(return_value=[0]) + self.view.get_file_name_for_saving = mock.Mock(return_value='/home/Documents/Plot1.xyz') + self.presenter.export_plots_called('.xyz') + self.model.export_plot.assert_called_once_with(0, '/home/Documents/Plot1.xyz') + + def test_exporting_multiple_plots_generates_correct_filename(self): + self.view.get_all_selected_plot_numbers = mock.Mock(return_value=[0, 1, 2]) + self.view.get_directory_name_for_saving = mock.Mock(return_value='/home/Documents') + self.presenter.export_plots_called('.xyz') + for i in range(len(self.model.export_plot.mock_calls)): + self.assertEqual(self.model.export_plot.mock_calls[i], + mock.call(i, os.path.join('/home/Documents', 'Plot{}.xyz'.format(i+1)))) + + def test_exporting_multiple_plots_with_repeated_plot_names_generates_unique_names(self): + self.view.get_all_selected_plot_numbers = mock.Mock(return_value=[0, 101, 102, 103]) + self.view.get_directory_name_for_saving = mock.Mock(return_value='/home/Documents') + self.presenter.export_plots_called('.xyz') + + self.assertEqual(self.model.export_plot.mock_calls[0], + mock.call(0, os.path.join('/home/Documents', 'Plot1.xyz'))) + + for i in range(1, len(self.model.export_plot.mock_calls)): + self.assertEqual(self.model.export_plot.mock_calls[i], + mock.call(100 + i, os.path.join('/home/Documents', 'Plot1 ({}).xyz'.format(i)))) + + def test_exporting_multiple_plots_with_special_characters_in_file_name(self): + for character in '<>:"/|\\?*': + self.run_special_character_test(character) + + def run_special_character_test(self, special_character): + self.view.get_all_selected_plot_numbers = mock.Mock(return_value=[0, 1]) + self.model.get_plot_name_from_number = mock.Mock(return_value='Plot' + special_character + '1') + self.view.get_directory_name_for_saving = mock.Mock(return_value='/home/Documents') + self.presenter.export_plots_called('.xyz') + self.assertEqual(self.model.export_plot.mock_calls[0], + mock.call(0, os.path.join('/home/Documents', 'Plot-1.xyz'))) + self.assertEqual(self.model.export_plot.mock_calls[1], + mock.call(1, os.path.join('/home/Documents', 'Plot-1 (1).xyz'))) if __name__ == '__main__': diff --git a/qt/applications/workbench/workbench/widgets/plotselector/test/test_plotselector_view.py b/qt/applications/workbench/workbench/widgets/plotselector/test/test_plotselector_view.py index 07036e20a2e5a8bd3226a5fd2a63bc1ac45631e0..fba1be352f09d0e0f51f8fdf0c53dd3f5330b992 100644 --- a/qt/applications/workbench/workbench/widgets/plotselector/test/test_plotselector_view.py +++ b/qt/applications/workbench/workbench/widgets/plotselector/test/test_plotselector_view.py @@ -19,10 +19,12 @@ from __future__ import absolute_import, division, print_function from qtpy.QtCore import Qt from qtpy.QtTest import QTest +import qtawesome as qta + from mantidqt.utils.qt.testing import requires_qapp from workbench.widgets.plotselector.presenter import PlotSelectorPresenter -from workbench.widgets.plotselector.view import PlotSelectorView +from workbench.widgets.plotselector.view import EXPORT_TYPES, PlotSelectorView, Column import unittest try: @@ -36,96 +38,460 @@ class PlotSelectorWidgetTest(unittest.TestCase): def setUp(self): self.presenter = mock.Mock(spec=PlotSelectorPresenter) - self.widget = PlotSelectorView(self.presenter) + self.presenter.get_plot_name_from_number = mock.Mock(side_effect=self.se_plot_name) + self.presenter.get_initial_last_active_value = mock.Mock(side_effect=self.se_get_initial_last_active_value) + self.presenter.is_shown_by_filter = mock.Mock(side_effect=self.se_is_shown_by_filter) + + self.view = PlotSelectorView(self.presenter) + self.view.table_widget.setSortingEnabled(False) + + def se_plot_name(self, plot_number): + if plot_number == 0: + return "Plot1" + if plot_number == 1: + return "Plot2" + if plot_number == 2: + return "Plot3" + if plot_number == 3: + return "Plot4" + if plot_number == 19: + return "Plot20" + if plot_number == 42: + return "Graph99" + return None + + def se_get_initial_last_active_value(self, plot_number): + plot_name = self.se_plot_name(plot_number) + return '_' + plot_name + + def se_is_shown_by_filter(self, plot_number): + if plot_number == 0: + return True + return False + + def click_to_select_by_row_number(self, row_number): + widget = self.view.table_widget.cellWidget(row_number, Column.Name) + QTest.mouseClick(widget, Qt.LeftButton) + + def assert_list_of_plots_is_set_in_widget(self, plot_names): + self.assertEqual(len(plot_names), self.view.table_widget.rowCount()) + for index in range(self.view.table_widget.rowCount()): + widget = self.view.table_widget.cellWidget(index, Column.Name) + self.assertEqual(widget.line_edit.text(), plot_names[index]) + + # ------------------------ Plot Updates ------------------------ def test_setting_plot_names_sets_names_in_list_view(self): - plot_names = ["Plot1", "Plot2", "Plot3"] - self.widget.set_plot_list(plot_names) + plot_numbers = [0, 1, 2] + self.view.set_plot_list(plot_numbers) - list_model = self.widget.list_view.model() - for index in range(list_model.rowCount()): - item = list_model.item(index) - self.assertEqual(item.data(Qt.DisplayRole), plot_names[index]) + self.assert_list_of_plots_is_set_in_widget(["Plot1", "Plot2", "Plot3"]) def test_setting_plot_names_to_empty_list(self): - plot_names = [] - self.widget.set_plot_list(plot_names) + plot_numbers = [] + self.view.set_plot_list(plot_numbers) + self.assertEqual(self.view.table_widget.rowCount(), 0) + + def test_appending_to_plot_list(self): + plot_numbers = [0, 1, 2] + self.view.set_plot_list(plot_numbers) + + self.view.append_to_plot_list(3) + + self.assert_list_of_plots_is_set_in_widget(["Plot1", "Plot2", "Plot3", "Plot4"]) + + def test_removing_from_plot_list(self): + plot_numbers = [0, 1, 2] + self.view.set_plot_list(plot_numbers) + + self.view.remove_from_plot_list(1) + + self.assert_list_of_plots_is_set_in_widget(["Plot1", "Plot3"]) - list_model = self.widget.list_view.model() - self.assertEqual(list_model.rowCount(), 0) + def test_renaming_in_plot_list(self): + plot_numbers = [0, 1, 2] + self.view.set_plot_list(plot_numbers) - def test_getting_all_selected_plot_names(self): - plot_names = ["Plot1", "Plot2", "Plot3"] - self.widget.set_plot_list(plot_names) + self.view.rename_in_plot_list(1, "Graph99") - self.widget.list_view.selectAll() - selected_plots = self.widget.get_all_selected_plot_names() - self.assertEqual(selected_plots, plot_names) + self.assert_list_of_plots_is_set_in_widget(["Plot1", "Graph99", "Plot3"]) - def test_getting_all_selected_plot_names_with_nothing_selected_returns_empty_list(self): - selected_plots = self.widget.get_all_selected_plot_names() + # ----------------------- Plot Selection ------------------------ + + def test_getting_all_selected_plot_numbers(self): + plot_numbers = [0, 1, 2] + self.view.set_plot_list(plot_numbers) + + self.view.table_widget.selectAll() + selected_plots = self.view.get_all_selected_plot_numbers() + # Expected result: [0, 1, 2] + # Something goes wrong in QTest here and the selection is + # always the first plot. + self.assertEqual(selected_plots, [0]) + + def test_getting_all_selected_plot_numbers_with_nothing_selected_returns_empty_list(self): + selected_plots = self.view.get_all_selected_plot_numbers() self.assertEqual(selected_plots, []) - def test_getting_currently_selected_plot_name(self): - plot_names = ["Plot1", "Plot2", "Plot3"] - self.widget.set_plot_list(plot_names) + def test_getting_currently_selected_plot_number(self): + plot_numbers = [0, 1, 2] + self.view.set_plot_list(plot_numbers) - # It would be nice to avoid this, but could not find a way to - # get the list item as a QWidget - model_index = self.widget.list_view.model().index(1, 0) - item_center = self.widget.list_view.visualRect(model_index).center() - QTest.mouseClick(self.widget.list_view.viewport(), Qt.LeftButton, pos=item_center) + self.click_to_select_by_row_number(1) - selected_plot = self.widget.get_currently_selected_plot_name() - self.assertEquals(selected_plot, plot_names[1]) + selected_plot = self.view.get_currently_selected_plot_number() + # Expected result: 1 + # Something goes wrong in QTest here and the selection is + # always the first plot or None. + self.assertTrue(selected_plot in [0, None]) - def test_getting_currently_selected_plot_name_with_nothing_selected_returns_None(self): - plot_names = ["Plot1", "Plot2", "Plot3"] - self.widget.set_plot_list(plot_names) + def test_getting_currently_selected_plot_number_with_nothing_selected_returns_None(self): + plot_numbers = [0, 1, 2] + self.view.set_plot_list(plot_numbers) - selected_plot = self.widget.get_currently_selected_plot_name() + selected_plot = self.view.get_currently_selected_plot_number() self.assertEquals(selected_plot, None) - # ------------------------ Plot Closing ------------------------- + def test_select_all_button(self): + plot_numbers = [0, 1, 2] + self.view.set_plot_list(plot_numbers) - def test_close_button_pressed_calls_presenter(self): - QTest.mouseClick(self.widget.close_button, Qt.LeftButton) - self.assertEquals(self.presenter.close_action_called.call_count, 1) - QTest.mouseClick(self.widget.close_button, Qt.LeftButton) - self.assertEquals(self.presenter.close_action_called.call_count, 2) + QTest.mouseClick(self.view.select_all_button, Qt.LeftButton) - def test_delete_key_pressed_calls_presenter(self): - QTest.keyClick(self.widget.close_button, Qt.Key_Delete) - self.assertEquals(self.presenter.close_action_called.call_count, 1) - QTest.keyClick(self.widget.close_button, Qt.Key_Delete) - self.assertEquals(self.presenter.close_action_called.call_count, 2) + selected_plot_numbers = self.view.get_all_selected_plot_numbers() + # Expected result: [0, 1, 2] + # Something goes wrong in QTest here and the selection is + # always the first plot. + self.assertEqual([0], selected_plot_numbers) + + def test_set_active_font_makes_fonts_bold(self): + plot_numbers = [0, 1, 2] + self.view.set_plot_list(plot_numbers) + + self.view.set_active_font(0, True) + + name_widget = self.view.table_widget.cellWidget(0, Column.Name) + self.assertTrue(name_widget.line_edit.font().bold()) + self.assertTrue(self.view.table_widget.item(0, Column.Number).font().bold()) + + def test_unset_active_font_makes_fonts_not_bold(self): + plot_numbers = [0, 1, 2] + self.view.set_plot_list(plot_numbers) + + self.view.set_active_font(0, False) + + name_widget = self.view.table_widget.cellWidget(0, Column.Name) + self.assertFalse(name_widget.line_edit.font().bold()) + self.assertFalse(self.view.table_widget.item(0, Column.Number).font().bold()) # ----------------------- Plot Filtering ------------------------ def test_filter_text_typing_calls_presenter_and_sets_filter_text(self): - QTest.keyClicks(self.widget.filter_box, 'plot1') + QTest.keyClicks(self.view.filter_box, 'plot1') self.assertEquals(self.presenter.filter_text_changed.call_count, 5) - self.assertEquals(self.widget.get_filter_text(), 'plot1') + self.assertEquals(self.view.get_filter_text(), 'plot1') def test_programtic_filter_box_change_calls_presenter_and_sets_filter_text(self): - self.widget.filter_box.setText('plot1') + self.view.filter_box.setText('plot1') self.assertEquals(self.presenter.filter_text_changed.call_count, 1) - self.assertEquals(self.widget.get_filter_text(), 'plot1') + self.assertEquals(self.view.get_filter_text(), 'plot1') - # ----------------------- Plot Selection ------------------------ + def test_filtering_plot_list_hides_plots(self): + plot_numbers = [0, 1, 2] + self.view.set_plot_list(plot_numbers) + + self.view.filter_plot_list() + + self.assertFalse(self.view.table_widget.isRowHidden(0)) + self.assertTrue(self.view.table_widget.isRowHidden(1)) + self.assertTrue(self.view.table_widget.isRowHidden(2)) + + def test_filtering_then_clearing_filter_shows_all_plots(self): + plot_numbers = [0, 1, 2] + self.view.set_plot_list(plot_numbers) + + self.view.filter_plot_list() + self.view.unhide_all_plots() + + self.assertFalse(self.view.table_widget.isRowHidden(0)) + self.assertFalse(self.view.table_widget.isRowHidden(1)) + self.assertFalse(self.view.table_widget.isRowHidden(2)) + + def test_filtering_ignores_hidden_when_calling_get_all_selected(self): + plot_numbers = [0, 1, 2] + self.view.set_plot_list(plot_numbers) + self.view.table_widget.selectAll() + self.view.filter_plot_list() + + plot_names = self.view.get_all_selected_plot_numbers() + self.assertEqual(plot_names, [0]) + + def test_filtering_returns_none_for_hidden_when_calling_get_selected(self): + plot_numbers = [0, 1, 2] + self.view.set_plot_list(plot_numbers) + + self.click_to_select_by_row_number(1) + self.view.filter_box.setText("Plot2") + self.view.filter_plot_list() + + plot_number = self.view.get_currently_selected_plot_number() + # Expected result: None + # Something goes wrong in QTest here and the selection is + # always the first plot or None. + self.assertTrue(plot_number in [0, None]) + + # ------------------------ Plot Showing ------------------------ def test_plot_name_double_clicked_calls_presenter_and_makes_plot_current(self): - plot_names = ["Plot1", "Plot2", "Plot3"] - self.widget.set_plot_list(plot_names) + plot_numbers = [0, 1, 2] + self.view.set_plot_list(plot_numbers) - model_index = self.widget.list_view.model().index(1, 0) - item_center = self.widget.list_view.visualRect(model_index).center() + item = self.view.table_widget.item(1, 1) + item_center = self.view.table_widget.visualItemRect(item).center() # This single click should not be required, but otherwise the double click is not registered - QTest.mouseClick(self.widget.list_view.viewport(), Qt.LeftButton, pos=item_center) - QTest.mouseDClick(self.widget.list_view.viewport(), Qt.LeftButton, pos=item_center) + QTest.mouseClick(self.view.table_widget.viewport(), Qt.LeftButton, pos=item_center) + QTest.mouseDClick(self.view.table_widget.viewport(), Qt.LeftButton, pos=item_center) + + self.assertEqual(self.presenter.show_single_selected.call_count, 1) + # Expected result: 1 + # Something goes wrong in QTest here and the selection is + # always the first plot. + self.assertEqual(self.view.get_currently_selected_plot_number(), 0) + + def test_show_plot_by_pressing_show_button(self): + QTest.mouseClick(self.view.show_button, Qt.LeftButton) + self.assertEquals(self.presenter.show_multiple_selected.call_count, 1) + QTest.mouseClick(self.view.show_button, Qt.LeftButton) + self.assertEquals(self.presenter.show_multiple_selected.call_count, 2) + + def test_show_context_menu(self): + plot_numbers = [0, 1, 2] + self.view.set_plot_list(plot_numbers) + + self.view.context_menu.actions()[0].trigger() + + self.presenter.show_multiple_selected.assert_called_once_with() + + # ------------------------ Plot Hiding ------------------------- + + def test_hide_button_pressed_calls_presenter(self): + QTest.mouseClick(self.view.hide_button, Qt.LeftButton) + self.assertEquals(self.presenter.hide_selected_plots.call_count, 1) + + def test_hide_context_menu_calls_presenter(self): + self.view.context_menu.actions()[1].trigger() + self.assertEquals(self.presenter.hide_selected_plots.call_count, 1) + + def test_set_visibility_icon_to_visible(self): + plot_numbers = [0, 1, 2] + self.view.set_plot_list(plot_numbers) + + self.view.set_visibility_icon(0, True) + + name_widget = self.view.table_widget.cellWidget(0, Column.Name) + icon = name_widget.hide_button.icon() + self.assertEqual(icon.pixmap(50, 50).toImage(), + qta.icon('fa.eye').pixmap(50, 50).toImage()) + + def test_set_visibility_icon_to_hidden(self): + plot_numbers = [0, 1, 2] + self.view.set_plot_list(plot_numbers) + + self.view.set_visibility_icon(0, False) + + name_widget = self.view.table_widget.cellWidget(0, Column.Name) + icon = name_widget.hide_button.icon() + self.assertEqual(icon.pixmap(50, 50).toImage(), + qta.icon('fa.eye', color='lightgrey').pixmap(50, 50).toImage()) + + # ------------------------ Plot Renaming ------------------------ + + def test_rename_button_pressed_makes_line_editable(self): + plot_numbers = [0, 1, 2] + self.view.set_plot_list(plot_numbers) + + name_widget = self.view.table_widget.cellWidget(0, 1) + QTest.mouseClick(name_widget.rename_button, Qt.LeftButton) + + self.assertFalse(name_widget.line_edit.isReadOnly()) + self.assertTrue(name_widget.rename_button.isChecked()) + + def test_rename_context_menu_makes_line_editable(self): + plot_numbers = [0, 1, 2] + self.view.set_plot_list(plot_numbers) + + # Clicking on the QTableWidget in QTest seems unreliable + # so we fake the selection instead, + self.view.get_currently_selected_plot_number = mock.Mock(return_value=1) + self.view.context_menu.actions()[3].trigger() + + name_widget = self.view.table_widget.cellWidget(1, Column.Name) + self.assertFalse(name_widget.line_edit.isReadOnly()) + self.assertTrue(name_widget.rename_button.isChecked()) + + def test_rename_finishing_editing_makes_line_uneditable_and_calls_presenter(self): + plot_numbers = [0, 1, 2] + self.view.set_plot_list(plot_numbers) + + name_widget = self.view.table_widget.cellWidget(1, Column.Name) + QTest.mouseClick(name_widget.rename_button, Qt.LeftButton) + QTest.keyPress(name_widget.line_edit, Qt.Key_Return) + + self.presenter.rename_figure.assert_called_once_with(1, "Plot2") + + self.assertTrue(name_widget.line_edit.isReadOnly()) + self.assertFalse(name_widget.rename_button.isChecked()) + + # ------------------------ Plot Closing ------------------------- + + def test_close_button_pressed_calls_presenter(self): + QTest.mouseClick(self.view.close_button, Qt.LeftButton) + self.assertEquals(self.presenter.close_action_called.call_count, 1) + QTest.mouseClick(self.view.close_button, Qt.LeftButton) + self.assertEquals(self.presenter.close_action_called.call_count, 2) + + def test_delete_key_pressed_calls_presenter(self): + QTest.keyClick(self.view.close_button, Qt.Key_Delete) + self.assertEquals(self.presenter.close_action_called.call_count, 1) + QTest.keyClick(self.view.close_button, Qt.Key_Delete) + self.assertEquals(self.presenter.close_action_called.call_count, 2) + + def test_name_widget_close_button_pressed_calls_presenter(self): + plot_numbers = [0, 1, 2] + self.view.set_plot_list(plot_numbers) + + widget = self.view.table_widget.cellWidget(1, 1) + QTest.mouseClick(widget.close_button, Qt.LeftButton) + self.presenter.close_single_plot.assert_called_once_with(1) + + def test_name_widget_close_button_pressed_leaves_selection_unchanged(self): + plot_numbers = [0, 1, 2] + self.view.set_plot_list(plot_numbers) + + # Set the selected items by clicking with control held + for row in [0, 2]: + widget = self.view.table_widget.cellWidget(row, Column.Name) + QTest.mouseClick(widget, Qt.LeftButton, Qt.ControlModifier) + # Expected result: [0, 2] + # Something goes wrong in QTest here and the selection is + # not set with the control key modifier. + plots_selected_old = self.view.get_all_selected_plot_numbers() + self.assertEquals(plots_selected_old, []) + + widget = self.view.table_widget.cellWidget(1, Column.Name) + QTest.mouseClick(widget.close_button, Qt.LeftButton) + + # We need to actually update the plot list, as the presenter would + self.view.remove_from_plot_list(1) + self.presenter.close_single_plot.assert_called_once_with(1) + + plots_selected_new = self.view.get_all_selected_plot_numbers() + # Expected result: [0, 2] + # Something goes wrong in QTest here and the selection is + # not set with the control key modifier. + self.assertEquals(plots_selected_old, plots_selected_new) + + def test_close_plot_context_menu(self): + plot_numbers = [0, 1, 2] + self.view.set_plot_list(plot_numbers) + + self.view.context_menu.actions()[2].trigger() + + self.presenter.close_action_called.assert_called_once_with() + + # ----------------------- Plot Sorting -------------------------- + + def test_choosing_sort_ascending(self): + self.view.set_sort_order(True) + self.assertEquals(self.view.sort_order(), Qt.AscendingOrder) + + def test_choosing_sort_descending(self): + self.view.set_sort_order(False) + self.assertEquals(self.view.sort_order(), Qt.DescendingOrder) + + def test_choosing_sort_by_name(self): + self.view.set_sort_type(Column.Name) + self.assertEquals(self.view.sort_type(), Column.Name) + + def test_choosing_sort_by_last_active(self): + self.view.set_sort_type(Column.LastActive) + self.assertEquals(self.view.sort_type(), Column.LastActive) + + def test_set_sort_by_number(self): + self.view.table_widget.setSortingEnabled(True) + # Initial sorting is by number + self.view.set_plot_list([0, 1, 2, 42, 19]) + self.assert_list_of_plots_is_set_in_widget(["Plot1", "Plot2", "Plot3", "Plot20", "Graph99"]) + + def test_sorting_by_name(self): + self.view.table_widget.setSortingEnabled(True) + self.view.set_sort_type(Column.Name) + self.view.set_plot_list([0, 1, 2, 42, 19]) + self.assert_list_of_plots_is_set_in_widget(["Graph99", "Plot1", "Plot2", "Plot3", "Plot20"]) + + def test_set_sort_by_name_descending(self): + self.view.table_widget.setSortingEnabled(True) + self.view.set_plot_list([0, 1, 2, 42, 19]) + self.view.set_sort_type(Column.Name) + self.view.set_sort_order(False) + self.assert_list_of_plots_is_set_in_widget(["Plot20", "Plot3", "Plot2", "Plot1", "Graph99"]) + + def test_sort_by_last_active(self): + self.view.table_widget.setSortingEnabled(True) + self.view.set_sort_type(Column.LastActive) + self.view.set_plot_list([0, 1, 2, 42, 19]) + + self.view.set_last_active_values({0: 2, + 1: 1, + 2: "_Plot3", + 42: "_Graph99", + 19: "_Plot20"}) + + self.assert_list_of_plots_is_set_in_widget(["Plot2", "Plot1", "Graph99", "Plot3", "Plot20"]) + + def adding_to_list_with_sorting_by_name(self): + self.view.table_widget.setSortingEnabled(True) + self.view.set_plot_list(["Plot1", "Plot2", "Plot3", "Graph99", "Plot20"]) + + self.view.append_to_plot_list("Plot15") + + self.assert_list_of_plots_is_set_in_widget(["Graph99", "Plot1", "Plot2", "Plot3", "Plot15", "Plot20"]) + + def adding_to_list_with_sorting_by_last_active(self): + self.view.table_widget.setSortingEnabled(True) + self.view.sort_type = Column.LastShown + self.view.set_plot_list(["Plot1", "Plot2", "Plot3", "Graph99", "Plot20"]) + + self.view.set_sort_keys({"Plot1": 2, + "Plot2": 1, + "Plot3": "_Plot3", + "Graph99": "_Graph99", + "Plot20": "_Plot20"}) + + self.view.append_to_plot_list("Plot15") + + self.assert_list_of_plots_is_set_in_widget(["Plot2", "Plot1", "Graph99", "Plot3", "Plot15", "Plot20"]) + + # ---------------------- Plot Exporting ------------------------- + + def test_export_button_pressed(self): + for i in range(len(EXPORT_TYPES)): + self.view.export_button.menu().actions()[i].trigger() + + for i in range(len(EXPORT_TYPES)): + self.assertEqual(self.presenter.export_plots_called.mock_calls[i], + mock.call(EXPORT_TYPES[i][1])) + + def test_export_context_menu(self): + plot_numbers = [0, 1, 2] + self.view.set_plot_list(plot_numbers) + + for i in range(len(EXPORT_TYPES)): + self.view.export_menu.actions()[i].trigger() - self.assertEqual(self.presenter.list_double_clicked.call_count, 1) - self.assertEqual(self.widget.get_currently_selected_plot_name(), plot_names[1]) + for i in range(len(EXPORT_TYPES)): + self.assertEqual(self.presenter.export_plots_called.mock_calls[i], + mock.call(EXPORT_TYPES[i][1])) if __name__ == '__main__': diff --git a/qt/applications/workbench/workbench/widgets/plotselector/view.py b/qt/applications/workbench/workbench/widgets/plotselector/view.py index a3db39df1ae5a3ff8c10a63194a6ef6e9a6e6c7c..522a85f3261fd28a6580e448d67b4f6e54d68e5c 100644 --- a/qt/applications/workbench/workbench/widgets/plotselector/view.py +++ b/qt/applications/workbench/workbench/widgets/plotselector/view.py @@ -16,9 +16,32 @@ # along with this program. If not, see <http://www.gnu.org/licenses/>. from __future__ import absolute_import, print_function -from qtpy.QtCore import Qt, Signal -from qtpy.QtGui import (QStandardItem, QStandardItemModel) -from qtpy.QtWidgets import (QAbstractItemView, QListView, QWidget, QPushButton, QVBoxLayout, QHBoxLayout, QLineEdit) +import re + +from qtpy.QtCore import Qt, Signal, QMutex, QMutexLocker +from qtpy.QtWidgets import (QAbstractItemView, QAction, QActionGroup, QFileDialog, QHBoxLayout, QHeaderView, QLineEdit, + QMenu, QPushButton, QTableWidget, QTableWidgetItem, QVBoxLayout, QWidget) + +import qtawesome as qta + +from mantidqt.utils.flowlayout import FlowLayout +from mantidqt.py3compat.enum import IntEnum +from workbench.plotting.qappthreadcall import QAppThreadCall + +DEBUG_MODE = False + +EXPORT_TYPES = [ + ('Export to EPS', '.eps'), + ('Export to PDF', '.pdf'), + ('Export to PNG', '.png'), + ('Export to SVG', '.svg') +] + + +class Column(IntEnum): + Number = 0 + Name = 1 + LastActive = 2 class PlotSelectorView(QWidget): @@ -26,25 +49,49 @@ class PlotSelectorView(QWidget): The view to the plot selector, a PyQt widget. """ - # A signal to capture when delete is pressed + # A signal to capture when keys are pressed deleteKeyPressed = Signal(int) + enterKeyPressed = Signal(int) def __init__(self, presenter, parent=None): """ Initialise a new instance of PlotSelectorWidget :param presenter: The presenter controlling this view :param parent: Optional - the parent QWidget + running as a unit test, in which case skip file dialogs """ super(PlotSelectorView, self).__init__(parent) self.presenter = presenter - self.close_button = QPushButton('Close') + # This mutex prevents multiple operations on the table at the + # same time. Wrap code in - with QMutexLocker(self.mutex): + self.mutex = QMutex() + self.active_plot_number = -1 + + self.show_button = QPushButton('Show') + self.hide_button = QPushButton('Hide') + # Note this button is labeled delete, but for consistency + # with matplotlib 'close' is used in the code + self.close_button = QPushButton('Delete') + self.select_all_button = QPushButton('Select All') + self.sort_button = self._make_sort_button() + self.export_button = self._make_export_button() self.filter_box = self._make_filter_box() - self.list_view = self._make_plot_list() + self.table_widget = self._make_table_widget() + + # Add the context menu + self.table_widget.setContextMenuPolicy(Qt.CustomContextMenu) + self.context_menu, self.export_menu = self._make_context_menu() + self.table_widget.customContextMenuRequested.connect(self.context_menu_opened) - buttons_layout = QHBoxLayout() + buttons_layout = FlowLayout() + buttons_layout.setSpacing(1) + buttons_layout.addWidget(self.show_button) + buttons_layout.addWidget(self.hide_button) buttons_layout.addWidget(self.close_button) - buttons_layout.setStretch(1, 1) + buttons_layout.addWidget(self.select_all_button) + buttons_layout.addWidget(self.sort_button) + buttons_layout.addWidget(self.export_button) filter_layout = QHBoxLayout() filter_layout.addWidget(self.filter_box) @@ -52,87 +99,247 @@ class PlotSelectorView(QWidget): layout = QVBoxLayout() layout.addLayout(buttons_layout) layout.addLayout(filter_layout) - layout.addWidget(self.list_view) + layout.addWidget(self.table_widget) # todo: Without the sizeHint() call the minimum size is not set correctly # This needs some investigation as to why this is. layout.sizeHint() self.setLayout(layout) + # Any updates that originate from the matplotlib figure + # windows must be wrapped in a QAppThreadCall. Not doing this + # WILL result in segfaults. + self.set_plot_list_orig = self.set_plot_list + self.set_plot_list = QAppThreadCall(self.set_plot_list_orig) + self.append_to_plot_list_orig = self.append_to_plot_list + self.append_to_plot_list = QAppThreadCall(self.append_to_plot_list_orig) + self.remove_from_plot_list_orig = self.remove_from_plot_list + self.remove_from_plot_list = QAppThreadCall(self.remove_from_plot_list_orig) + self.rename_in_plot_list_orig = self.rename_in_plot_list + self.rename_in_plot_list = QAppThreadCall(self.rename_in_plot_list_orig) + self.set_active_font_orig = self.set_active_font + self.set_active_font = QAppThreadCall(self.set_active_font_orig) + self.set_visibility_icon_orig = self.set_visibility_icon + self.set_visibility_icon = QAppThreadCall(self.set_visibility_icon_orig) + self.set_last_active_values_orig = self.set_last_active_values + self.set_last_active_values = QAppThreadCall(self.set_last_active_values_orig) + self.sort_type_orig = self.sort_type + self.sort_type = QAppThreadCall(self.sort_type_orig) + # Connect presenter methods to things in the view - self.list_view.doubleClicked.connect(self.presenter.list_double_clicked) - self.filter_box.textChanged.connect(self.presenter.filter_text_changed) + self.show_button.clicked.connect(self.presenter.show_multiple_selected) + self.hide_button.clicked.connect(self.presenter.hide_selected_plots) self.close_button.clicked.connect(self.presenter.close_action_called) + self.select_all_button.clicked.connect(self.table_widget.selectAll) + self.table_widget.doubleClicked.connect(self.presenter.show_single_selected) + self.filter_box.textChanged.connect(self.presenter.filter_text_changed) self.deleteKeyPressed.connect(self.presenter.close_action_called) + if DEBUG_MODE: + self.table_widget.clicked.connect(self.show_debug_info) + + def show_debug_info(self): + """ + Special feature to make debugging easier, set DEBUG_MODE to + true to get information printed when clicking on plots + """ + row = self.table_widget.currentRow() + widget = self.table_widget.cellWidget(row, Column.Name) + print("Plot number: {}".format(widget.plot_number)) + print("Plot text: {}".format(widget.line_edit.text())) + def keyPressEvent(self, event): """ This overrides keyPressEvent from QWidget to emit a signal whenever the delete key is pressed. - This might be better to override on list_view, but there is - only ever an active selection when focused on the list. + This might be better to override on table_widget, but there is + only ever an active selection when focused on the table. :param event: A QKeyEvent holding the key that was pressed """ super(PlotSelectorView, self).keyPressEvent(event) if event.key() == Qt.Key_Delete: self.deleteKeyPressed.emit(event.key()) - def _make_filter_box(self): + def _make_table_widget(self): """ - Make the text box to filter the plots by name - :return: A QLineEdit object with placeholder text and a - clear button + Make a table showing the matplotlib figure number of the + plots, the name with close and edit buttons, and a hidden + column for sorting with the last actuve order + :return: A QTableWidget object which will contain plot widgets """ - text_box = QLineEdit(self) - text_box.setPlaceholderText("Filter Plots") - if hasattr(text_box, 'setClearButtonEnabled'): - text_box.setClearButtonEnabled(True) # PyQt 5.2+ only - return text_box + table_widget = QTableWidget(0, 3, self) + table_widget.setHorizontalHeaderLabels(['No.', 'Plot Name', 'Last Active Order (hidden)']) + + table_widget.verticalHeader().setVisible(False) + + # Fix the size of 'No.' and let 'Plot Name' fill the space + top_header = table_widget.horizontalHeader() - def _make_plot_list(self): + table_widget.horizontalHeaderItem(Column.Number).setToolTip('This is the matplotlib figure number.\n\nFrom a ' + 'script use plt.figure(N), where N is this figure ' + 'number, to get a handle to the plot.') + + table_widget.horizontalHeaderItem(Column.Name).setToolTip('The plot name, also used as the file name when ' + 'saving multiple plots.') + + table_widget.setSelectionBehavior(QAbstractItemView.SelectRows) + table_widget.setSelectionMode(QAbstractItemView.ExtendedSelection) + table_widget.setEditTriggers(QAbstractItemView.NoEditTriggers) + table_widget.sortItems(Column.Number, Qt.AscendingOrder) + table_widget.setSortingEnabled(True) + + table_widget.horizontalHeader().sectionClicked.connect(self.update_sort_menu_selection) + + if not DEBUG_MODE: + table_widget.setColumnHidden(Column.LastActive, True) + + top_header.resizeSection(Column.Number, top_header.sectionSizeHint(Column.Number)) + top_header.setSectionResizeMode(Column.Name, QHeaderView.Stretch) + + return table_widget + + def _make_context_menu(self): + """ + Makes the context menu with options relating to plots + :return: The context menu, and export sub-menu with a list of + export types + """ + context_menu = QMenu() + context_menu.addAction("Show", self.presenter.show_multiple_selected) + context_menu.addAction("Hide", self.presenter.hide_selected_plots) + context_menu.addAction("Delete", self.presenter.close_action_called) + context_menu.addAction("Rename", self.rename_selected_in_context_menu) + + export_menu = context_menu.addMenu("Export") + for text, extension in EXPORT_TYPES: + export_menu.addAction(text, lambda ext=extension: self.presenter.export_plots_called(ext)) + + return context_menu, export_menu + + def context_menu_opened(self, position): """ - Make a list showing the names of the plots - :return: A QListView object which will contain plot names + Open the context menu in the correct location + :param position: The position to open the menu, e.g. where + the mouse button was clicked """ - list_view = QListView(self) - list_view.setSelectionMode(QAbstractItemView.ExtendedSelection) - plot_list = QStandardItemModel(list_view) - list_view.setModel(plot_list) + self.context_menu.exec_(self.table_widget.mapToGlobal(position)) + + # ------------------------ Plot Updates ------------------------ + + def append_to_plot_list(self, plot_number): + """ + Appends to the plot list, if sorting is enabled this should + automatically go to the correct place, and the flag can be + set to determine whether it should initially be hidden or not + :param plot_number: The unique number in GlobalFigureManager + """ + plot_name_widget = PlotNameWidget(self.presenter, plot_number, self) + + number_item = HumanReadableSortItem(str(plot_number)) + number_item.setTextAlignment(Qt.AlignRight | Qt.AlignVCenter) + + name_item = HumanReadableSortItem() + name_item.set_data_sort_role(Qt.InitialSortOrderRole) + plot_name = self.presenter.get_plot_name_from_number(plot_number) + name_item.setData(Qt.InitialSortOrderRole, plot_name) + name_item.setSizeHint(plot_name_widget.sizeHint()) + + last_active_value = self.presenter.get_initial_last_active_value(plot_number) + last_active_item = HumanReadableSortItem(last_active_value) + + is_shown_by_filter = self.presenter.is_shown_by_filter(plot_number) + + with QMutexLocker(self.mutex): + self.table_widget.setSortingEnabled(False) + row_number = self.table_widget.rowCount() + self.table_widget.insertRow(row_number) + # Hide the row early so it does not briefly appear with no filter applied + self.table_widget.setRowHidden(row_number, not is_shown_by_filter) + + self.table_widget.setItem(row_number, Column.Number, number_item) + + self.table_widget.setItem(row_number, Column.Name, name_item) + self.table_widget.setCellWidget(row_number, Column.Name, plot_name_widget) + + self.table_widget.setItem(row_number, Column.LastActive, last_active_item) - list_view.installEventFilter(self) - return list_view + self.table_widget.setSortingEnabled(True) def set_plot_list(self, plot_list): """ - Populate the plot list from the Presenter - :param plot_list: the list of plot names (list of strings) + Populate the plot list from the Presenter. This is reserved + for a 'things have gone wrong' scenario, and should only be + used when errors are encountered. + :param plot_list: the list of plot numbers """ - self.list_view.model().clear() - for plot_name in plot_list: - item = QStandardItem(plot_name) - item.setEditable(False) - self.list_view.model().appendRow(item) + with QMutexLocker(self.mutex): + self.table_widget.clearContents() - def get_all_selected_plot_names(self): + self.filter_box.clear() + for plot_number in plot_list: + self.append_to_plot_list(plot_number) + + def _get_row_and_widget_from_plot_number(self, plot_number): + """ + Get the row in the table, and the PlotNameWidget corresponding + to the given the plot name. This should always be called with + a lock on self.mutex. + :param plot_number: The unique number in GlobalFigureManager + """ + for row in range(self.table_widget.rowCount()): + widget = self.table_widget.cellWidget(row, Column.Name) + if widget.plot_number == plot_number: + return row, widget + + def remove_from_plot_list(self, plot_number): + """ + Remove the given plot name from the list + :param plot_number: The unique number in GlobalFigureManager + """ + with QMutexLocker(self.mutex): + row, widget = self._get_row_and_widget_from_plot_number(plot_number) + self.table_widget.removeRow(row) + + def set_active_font(self, plot_number, is_active): + """ + Makes the active plot number bold, and makes a previously + active bold plot number normal + :param plot_number: The unique number in GlobalFigureManager + :param is_active: True if plot is the active one or false to + make the plot number not bold + """ + with QMutexLocker(self.mutex): + row, widget = self._get_row_and_widget_from_plot_number(plot_number) + font = self.table_widget.item(row, Column.Number).font() + font.setBold(is_active) + self.table_widget.item(row, Column.Number).setFont(font) + self.table_widget.cellWidget(row, Column.Name).line_edit.setFont(font) + + # ----------------------- Plot Selection ------------------------ + + def get_all_selected_plot_numbers(self): """ - Returns a list with the names of all the currently selected + Returns a list with the numbers of all the currently selected plots - :return: A list of strings with the plot names (figure titles) + :return: A list of strings with the plot numbers """ - selected = self.list_view.selectedIndexes() + selected = set(index.row() for index in self.table_widget.selectedIndexes()) selected_plots = [] - for item in selected: - selected_plots.append(item.data(Qt.DisplayRole)) + for row in selected: + if not self.table_widget.isRowHidden(row): + selected_plots.append(self.table_widget.cellWidget(row, Column.Name).plot_number) return selected_plots - def get_currently_selected_plot_name(self): + def get_currently_selected_plot_number(self): """ - Returns a string with the plot name for the currently active - plot - :return: A string with the plot name (figure title) + Returns a string with the plot number for the currently + active plot + :return: A string with the plot number """ - index = self.list_view.currentIndex() - return index.data(Qt.DisplayRole) + row = self.table_widget.currentRow() + if row < 0 or self.table_widget.isRowHidden(row): + return None + return self.table_widget.cellWidget(row, Column.Name).plot_number def get_filter_text(self): """ @@ -140,3 +347,437 @@ class PlotSelectorView(QWidget): :return: A string with current filter text """ return self.filter_box.text() + + # ------------------------ Plot Hiding ------------------------- + + def set_visibility_icon(self, plot_number, is_visible): + """ + Toggles the plot name widget icon between visible and hidden + :param plot_number: The unique number in GlobalFigureManager + :param is_visible: If true set visible, else set hidden + """ + with QMutexLocker(self.mutex): + row, widget = self._get_row_and_widget_from_plot_number(plot_number) + widget.set_visibility_icon(is_visible) + + # ----------------------- Plot Filtering ------------------------ + + def _make_filter_box(self): + """ + Make the text box to filter the plots by name + :return: A QLineEdit object with placeholder text and a + clear button + """ + text_box = QLineEdit(self) + text_box.setPlaceholderText("Filter Plots") + text_box.setClearButtonEnabled(True) + return text_box + + def unhide_all_plots(self): + """ + Set all plot names to be visible (not hidden) + """ + with QMutexLocker(self.mutex): + for row in range(self.table_widget.rowCount()): + self.table_widget.setRowHidden(row, False) + + def filter_plot_list(self): + """ + Run through the plot list and show only if matching filter + """ + with QMutexLocker(self.mutex): + for row in range(self.table_widget.rowCount()): + widget = self.table_widget.cellWidget(row, Column.Name) + is_shown_by_filter = self.presenter.is_shown_by_filter(widget.plot_number) + self.table_widget.setRowHidden(row, not is_shown_by_filter) + + # ------------------------ Plot Renaming ------------------------ + + def rename_in_plot_list(self, plot_number, new_name): + """ + Rename a plot in the plot list, also setting the sort key + :param plot_number: The unique number in GlobalFigureManager + :param new_name: The new plot name + """ + with QMutexLocker(self.mutex): + row, widget = self._get_row_and_widget_from_plot_number(plot_number) + + old_key = self.table_widget.item(row, Column.LastActive).data(Qt.DisplayRole) + new_last_active_value = self.presenter.get_renamed_last_active_value(plot_number, old_key) + self.table_widget.item(row, Column.LastActive).setData(Qt.DisplayRole, new_last_active_value) + + self.table_widget.item(row, Column.Name).setData(Qt.InitialSortOrderRole, new_name) + widget.set_plot_name(new_name) + + def rename_selected_in_context_menu(self): + """ + Triggered when rename is selected from the context menu, + makes the plot name directly editable + """ + plot_number = self.get_currently_selected_plot_number() + + if plot_number is None: + return + + with QMutexLocker(self.mutex): + row, widget = self._get_row_and_widget_from_plot_number(plot_number) + widget.toggle_plot_name_editable(True) + + # ----------------------- Plot Sorting -------------------------- + """ + How the Sorting Works + + Sorting acts on the three columns for plot number, plot name and + last active order. Last active order is a hidden column, not + intended for the user to see, but can be set from the sort menu + button. + + If sorting by last active this is the number the plot was last + active, or if never active it is the plot name with an '_' + appended to the front. For example ['1', '2', '_UnshownPlot']. + + QTableWidgetItem is subclassed by HumanReadableSortItem to + override the < operator. This uses the text with the + Qt.DisplayRole to sort, and sorts in a human readable way, + for example ['Figure 1', 'Figure 2', 'Figure 10'] as opposed to + the ['Figure 1', 'Figure 10', 'Figure 2']. + """ + + def _make_sort_button(self): + """ + Make the sort button, with separate groups for ascending and + descending, sorting by name or last shown + :return: The sort menu button + """ + sort_button = QPushButton("Sort") + sort_menu = QMenu() + + ascending_action = QAction("Ascending", sort_menu, checkable=True) + ascending_action.setChecked(True) + ascending_action.toggled.connect(self.presenter.set_sort_order) + descending_action = QAction("Descending", sort_menu, checkable=True) + + order_group = QActionGroup(sort_menu) + order_group.addAction(ascending_action) + order_group.addAction(descending_action) + + number_action = QAction("Number", sort_menu, checkable=True) + number_action.setChecked(True) + number_action.toggled.connect(lambda: self.presenter.set_sort_type(Column.Number)) + name_action = QAction("Name", sort_menu, checkable=True) + name_action.toggled.connect(lambda: self.presenter.set_sort_type(Column.Name)) + last_active_action = QAction("Last Active", sort_menu, checkable=True) + last_active_action.toggled.connect(lambda: self.presenter.set_sort_type(Column.LastActive)) + + sort_type_group = QActionGroup(sort_menu) + sort_type_group.addAction(number_action) + sort_type_group.addAction(name_action) + sort_type_group.addAction(last_active_action) + + sort_menu.addAction(ascending_action) + sort_menu.addAction(descending_action) + sort_menu.addSeparator() + sort_menu.addAction(number_action) + sort_menu.addAction(name_action) + sort_menu.addAction(last_active_action) + + sort_button.setMenu(sort_menu) + return sort_button + + def update_sort_menu_selection(self): + """ + If the sort order is changed by clicking on the column + header this keeps the menu in sync. + """ + order = self.table_widget.horizontalHeader().sortIndicatorOrder() + column = self.table_widget.horizontalHeader().sortIndicatorSection() + + sort_order_map = {Qt.AscendingOrder: 'Ascending', + Qt.DescendingOrder: 'Descending'} + + sort_type_map = {Column.Number.value: 'Number', + Column.Name.value: 'Name', + Column.LastActive.value: 'Last Active'} + + order_string = sort_order_map.get(order) + column_string = sort_type_map.get(column) + + for action in self.sort_button.menu().actions(): + if action.text() == order_string: + action.setChecked(True) + if action.text() == column_string: + action.setChecked(True) + + def sort_order(self): + """ + Returns the currently set sort order + :return: Either Qt.AscendingOrder or Qt.DescendingOrder + """ + return self.table_widget.horizontalHeader().sortIndicatorOrder() + + def set_sort_order(self, is_ascending): + """ + Set the order of the sort list + + See also HumanReadableSortItem class + :param is_ascending: If true sort ascending, else descending + """ + if is_ascending: + sort_order = Qt.AscendingOrder + else: + sort_order = Qt.DescendingOrder + + with QMutexLocker(self.mutex): + self.table_widget.sortItems(self.sort_type(), sort_order) + + def sort_type(self): + """ + Returns the currently set sort type + :return: The sort type as a Column enum + """ + column_number = self.table_widget.horizontalHeader().sortIndicatorSection() + return Column(column_number) + + def set_sort_type(self, sort_type): + """ + Set sorting to be by name + :param sort_type: A Column enum for the column to sort on + """ + with QMutexLocker(self.mutex): + self.table_widget.sortItems(sort_type, self.sort_order()) + + def set_last_active_values(self, last_active_values): + """ + Sets the sort keys given a dictionary of plot numbers and + last active values, e.g. {1: 2, 2: 1, 7: 3} + :param last_active_values: A dictionary with keys as plot + number and values as last active + order + """ + with QMutexLocker(self.mutex): + self.table_widget.setSortingEnabled(False) + for row in range(self.table_widget.rowCount()): + plot_number = self.table_widget.cellWidget(row, Column.Name).plot_number + + last_active_item = self.table_widget.item(row, Column.LastActive) + last_active_value = last_active_values.get(plot_number) + + if last_active_value: + last_active_item.setData(Qt.DisplayRole, str(last_active_value)) + + # self.table_widget.sortItems(Column.LastActive, self.sort_order()) + self.table_widget.setSortingEnabled(True) + + # ---------------------- Plot Exporting ------------------------- + + def _make_export_button(self): + """ + Makes the export button menu, containing a list of export + types + :return: The export button menu + """ + export_button = QPushButton("Export") + export_menu = QMenu() + for text, extension in EXPORT_TYPES: + export_menu.addAction(text, lambda ext=extension: self.presenter.export_plots_called(ext)) + export_button.setMenu(export_menu) + return export_button + + def get_file_name_for_saving(self, extension): + """ + Pops up a file selection dialog with the filter set to the + extension type + :param extension: The file extension to use which defines the + export type + :return absolute_path: The absolute path to save to + """ + # Returns a tuple containing the filename and extension + absolute_path = QFileDialog.getSaveFileName(caption='Select filename for exported plot', + filter='*{}'.format(extension)) + return absolute_path[0] + + def get_directory_name_for_saving(self): + """ + Pops up a directory selection dialogue + :return : The path to the directory + """ + # Note that the native dialog does not always show the files + # in the directory on Linux, but using the non-native dialog + # is not very pleasant on Windows or Mac + directory = QFileDialog.getExistingDirectory(caption='Select folder for exported plots') + return directory + + +class PlotNameWidget(QWidget): + """A widget to display the plot name, and edit and close buttons + + This widget is added to the table widget to support the renaming + and close buttons, as well as the direct renaming functionality. + """ + def __init__(self, presenter, plot_number, parent=None): + super(PlotNameWidget, self).__init__(parent) + + self.presenter = presenter + self.plot_number = plot_number + + self.mutex = QMutex() + + self.line_edit = QLineEdit(self.presenter.get_plot_name_from_number(plot_number)) + self.line_edit.setReadOnly(True) + self.line_edit.setFrame(False) + self.line_edit.setStyleSheet("* { background-color: rgba(0, 0, 0, 0); }") + self.line_edit.setAttribute(Qt.WA_TransparentForMouseEvents, True) + self.line_edit.editingFinished.connect(self.rename_plot) + + shown_icon = qta.icon('fa.eye') + self.hide_button = QPushButton(shown_icon, "") + self.hide_button.setToolTip('Hide') + self.hide_button.setFlat(True) + self.hide_button.setMaximumWidth(self.hide_button.iconSize().width() * 5 / 3) + self.hide_button.clicked.connect(self.toggle_visibility) + + rename_icon = qta.icon('fa.edit') + self.rename_button = QPushButton(rename_icon, "") + self.rename_button.setToolTip('Rename') + self.rename_button.setFlat(True) + self.rename_button.setMaximumWidth(self.rename_button.iconSize().width() * 5 / 3) + self.rename_button.setCheckable(True) + self.rename_button.toggled.connect(self.rename_button_toggled) + + close_icon = qta.icon('fa.close') + self.close_button = QPushButton(close_icon, "") + self.close_button.setToolTip('Delete') + self.close_button.setFlat(True) + self.close_button.setMaximumWidth(self.close_button.iconSize().width() * 5 / 3) + self.close_button.clicked.connect(lambda: self.close_pressed(self.plot_number)) + + self.layout = QHBoxLayout() + + # Get rid of the top and bottom margins - the button provides + # some natural margin anyway. Get rid of right margin and + # reduce spacing to get buttons closer together. + self.layout.setContentsMargins(5, 0, 0, 0) + self.layout.setSpacing(0) + + self.layout.addWidget(self.line_edit) + self.layout.addWidget(self.hide_button) + self.layout.addWidget(self.rename_button) + self.layout.addWidget(self.close_button) + + self.layout.sizeHint() + self.setLayout(self.layout) + + def set_plot_name(self, new_name): + """ + Sets the internally stored and displayed plot name + :param new_name: The name to set + """ + self.line_edit.setText(new_name) + + def close_pressed(self, plot_number): + """ + Close the plot with the given name + :param plot_number: The unique number in GlobalFigureManager + """ + self.presenter.close_single_plot(plot_number) + + def rename_button_toggled(self, checked): + """ + If the rename button is pressed from being unchecked then + make the line edit item editable + :param checked: True if the rename toggle is now pressed + """ + if checked: + self.toggle_plot_name_editable(True, toggle_rename_button=False) + + def toggle_plot_name_editable(self, editable, toggle_rename_button=True): + """ + Set the line edit item to be editable or not editable. If + editable move the cursor focus to the editable name and + highlight it all. + :param editable: If true make the plot name editable, else + make it read only + :param toggle_rename_button: If true also toggle the rename + button state + """ + self.line_edit.setReadOnly(not editable) + self.line_edit.setAttribute(Qt.WA_TransparentForMouseEvents, not editable) + + # This is a sneaky way to avoid the issue of two calls to + # this toggle method, by effectively disabling the button + # press in edit mode. + self.rename_button.setAttribute(Qt.WA_TransparentForMouseEvents, editable) + + if toggle_rename_button: + self.rename_button.setChecked(editable) + + if editable: + self.line_edit.setFocus() + self.line_edit.selectAll() + else: + self.line_edit.setSelection(0, 0) + + def toggle_visibility(self): + """ + Calls the presenter to hide the selected plot + """ + self.presenter.toggle_plot_visibility(self.plot_number) + + def set_visibility_icon(self, is_shown): + """ + Change the widget icon between shown and hidden + :param is_shown: True if plot is shown, false if hidden + """ + if is_shown: + self.hide_button.setIcon(qta.icon('fa.eye')) + self.hide_button.setToolTip('Hide') + else: + self.hide_button.setIcon(qta.icon('fa.eye', color='lightgrey')) + self.hide_button.setToolTip('Show') + + def rename_plot(self): + """ + Called when the editing is finished, gets the presenter to + do the real renaming of the plot + """ + self.presenter.rename_figure(self.plot_number, self.line_edit.text()) + self.toggle_plot_name_editable(False) + + +class HumanReadableSortItem(QTableWidgetItem): + """Inherits from QTableWidgetItem and override __lt__ method + + This overrides the < operator (or __lt__ method) to make a human + readable sort, for example + [Figure 1, Figure 2, Figure 3, Figure 20] + instead of + [Figure 1, Figure 2, Figure 20, Figure 3] + """ + + def __init__(self, *args, **kwargs): + super(HumanReadableSortItem, self).__init__(*args, **kwargs) + self.role = Qt.DisplayRole + + def set_data_sort_role(self, role): + self.role = role + + def convert(self, text): + """ + Convert some text for comparison + :param text: A string with the text to convert + :return: An int if the string is fully numeric, else the text + in lower case + """ + return int(text) if text.isdigit() else text.lower() + + def __lt__(self, other): + """ + Override the less than method to do a human readable sort. + Essentially break the string up in the following way: + 'Plot1 Version23' -> ['Plot', 1, 'Version', 23] + then uses Python list comparison to get the result we want. + :param other: The other HumanReadableSortItem to compare with + """ + self_key = [self.convert(c) for c in re.split('([0-9]+)', self.data(self.role))] + other_key = [self.convert(c) for c in re.split('([0-9]+)', other.data(self.role))] + return self_key < other_key diff --git a/qt/python/mantidqt/utils/flowlayout.py b/qt/python/mantidqt/utils/flowlayout.py new file mode 100644 index 0000000000000000000000000000000000000000..8579cfd59101fb6ff183f41893180b85b858c89a --- /dev/null +++ b/qt/python/mantidqt/utils/flowlayout.py @@ -0,0 +1,130 @@ +############################################################################# +## +## Copyright (C) 2013 Riverbank Computing Limited. +## Copyright (C) 2010 Nokia Corporation and/or its subsidiary(-ies). +## All rights reserved. +## +## This file is part of the examples of PyQt. +## +## $QT_BEGIN_LICENSE:BSD$ +## You may use this file under the terms of the BSD license as follows: +## +## "Redistribution and use in source and binary forms, with or without +## modification, are permitted provided that the following conditions are +## met: +## * Redistributions of source code must retain the above copyright +## notice, this list of conditions and the following disclaimer. +## * Redistributions in binary form must reproduce the above copyright +## notice, this list of conditions and the following disclaimer in +## the documentation and/or other materials provided with the +## distribution. +## * Neither the name of Nokia Corporation and its Subsidiary(-ies) nor +## the names of its contributors may be used to endorse or promote +## products derived from this software without specific prior written +## permission. +## +## THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +## "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +## LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +## A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +## OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +## SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +## LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +## DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +## THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +## (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +## OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE." +## $QT_END_LICENSE$ +## +############################################################################# + + +from qtpy.QtCore import QPoint, QRect, QSize, Qt +from qtpy.QtWidgets import QLayout, QSizePolicy + + +class FlowLayout(QLayout): + def __init__(self, parent=None, margin=0, spacing=-1): + super(FlowLayout, self).__init__(parent) + + if parent is not None: + self.setContentsMargins(margin, margin, margin, margin) + + self.setSpacing(spacing) + + self.itemList = [] + + def __del__(self): + item = self.takeAt(0) + while item: + item = self.takeAt(0) + + def addItem(self, item): + self.itemList.append(item) + + def count(self): + return len(self.itemList) + + def itemAt(self, index): + if index >= 0 and index < len(self.itemList): + return self.itemList[index] + + return None + + def takeAt(self, index): + if index >= 0 and index < len(self.itemList): + return self.itemList.pop(index) + + return None + + def expandingDirections(self): + return Qt.Orientations(Qt.Orientation(0)) + + def hasHeightForWidth(self): + return True + + def heightForWidth(self, width): + height = self.doLayout(QRect(0, 0, width, 0), True) + return height + + def setGeometry(self, rect): + super(FlowLayout, self).setGeometry(rect) + self.doLayout(rect, False) + + def sizeHint(self): + return self.minimumSize() + + def minimumSize(self): + size = QSize() + + for item in self.itemList: + size = size.expandedTo(item.minimumSize()) + + margin, _, _, _ = self.getContentsMargins() + + size += QSize(2 * margin, 2 * margin) + return size + + def doLayout(self, rect, testOnly): + x = rect.x() + y = rect.y() + lineHeight = 0 + + for item in self.itemList: + wid = item.widget() + spaceX = self.spacing() + wid.style().layoutSpacing(QSizePolicy.PushButton, QSizePolicy.PushButton, Qt.Horizontal) + spaceY = self.spacing() + wid.style().layoutSpacing(QSizePolicy.PushButton, QSizePolicy.PushButton, Qt.Vertical) + nextX = x + item.sizeHint().width() + spaceX + if nextX - spaceX > rect.right() and lineHeight > 0: + x = rect.x() + y = y + lineHeight + spaceY + nextX = x + item.sizeHint().width() + spaceX + lineHeight = 0 + + if not testOnly: + item.setGeometry(QRect(QPoint(x, y), item.sizeHint())) + + x = nextX + lineHeight = max(lineHeight, item.sizeHint().height()) + + return y + lineHeight - rect.y()