diff --git a/Framework/PythonInterface/mantid/api/src/Exports/ITableWorkspace.cpp b/Framework/PythonInterface/mantid/api/src/Exports/ITableWorkspace.cpp index d1a9f270e68717e259c0964afd9f10fea8b747ca..99108d3813d9b43bd8ddf16e715ce13bbfbfa7eb 100644 --- a/Framework/PythonInterface/mantid/api/src/Exports/ITableWorkspace.cpp +++ b/Framework/PythonInterface/mantid/api/src/Exports/ITableWorkspace.cpp @@ -12,6 +12,7 @@ #include "MantidAPI/WorkspaceProperty.h" #include "MantidKernel/V3D.h" #include "MantidPythonInterface/core/NDArray.h" +#include "MantidPythonInterface/core/VersionCompat.h" #include "MantidPythonInterface/kernel/Converters/CloneToNumpy.h" #include "MantidPythonInterface/kernel/Converters/NDArrayToVector.h" #include "MantidPythonInterface/kernel/Converters/PySequenceToVector.h" @@ -26,6 +27,7 @@ #include <boost/python/dict.hpp> #include <boost/python/list.hpp> #include <boost/python/make_constructor.hpp> + #include <cstring> #include <vector> @@ -296,6 +298,27 @@ PyObject *row(ITableWorkspace &self, int row) { return result; } +/** + * Return the C++ types for all columns + * @param self A reference to the TableWorkspace python object that we were + * called on + */ +PyObject *columnTypes(ITableWorkspace &self) { + int numCols = static_cast<int>(self.columnCount()); + + PyObject *list = PyList_New(numCols); + + for (int col = 0; col < numCols; col++) { + Mantid::API::Column_const_sptr column = self.getColumn(col); + const std::type_info &typeID = column->get_type_info(); + if (PyList_SetItem(list, col, FROM_CSTRING(typeID.name()))) { + throw std::runtime_error("Error while building list"); + } + } + + return list; +} + /** * Adds a new row in the table, where the items are given in a dictionary * object mapping {column name:value}. It must contain a key-value entry for @@ -629,6 +652,9 @@ void export_ITableWorkspace() { .def("row", &row, (arg("self"), arg("row")), "Return all values of a specific row as a dict.") + .def("columnTypes", &columnTypes, arg("self"), + "Return the types of the columns as a list") + // FromSequence must come first since it takes an object parameter // Otherwise, FromDict will never be called as object accepts anything .def("addRow", &addRowFromSequence, (arg("self"), arg("row_items_seq")), diff --git a/Framework/PythonInterface/plugins/algorithms/StatisticsOfTableWorkspace.py b/Framework/PythonInterface/plugins/algorithms/StatisticsOfTableWorkspace.py index f148fc24d9f8bac466243921ecbedbf96d11223f..6b9d25ad702e0473b2a9902914f87772647b6371 100644 --- a/Framework/PythonInterface/plugins/algorithms/StatisticsOfTableWorkspace.py +++ b/Framework/PythonInterface/plugins/algorithms/StatisticsOfTableWorkspace.py @@ -4,30 +4,33 @@ # NScD Oak Ridge National Laboratory, European Spallation Source # & Institut Laue - Langevin # SPDX - License - Identifier: GPL - 3.0 + -#pylint: disable=no-init +# pylint: disable=no-init from __future__ import (absolute_import, division, print_function) -from mantid.api import PythonAlgorithm, AlgorithmFactory, ITableWorkspaceProperty -from mantid.kernel import Direction, Stats -import mantid.simpleapi as ms -from mantid import mtd, logger -import numpy as np + import collections + +import numpy as np from six import iteritems +import mantid.simpleapi as ms +from mantid import logger, mtd +from mantid.api import AlgorithmFactory, ITableWorkspaceProperty, PythonAlgorithm +from mantid.kernel import Direction, IntArrayBoundedValidator, IntArrayProperty, Stats + def _stats_to_dict(stats): """ - Converts a Statstics object to an ordered dictionary. + Converts a Statistics object to an ordered dictionary. @param stats Statistics object to convertToWaterfall @return Dictionary of statistics """ stat_dict = collections.OrderedDict() - stat_dict['standard_deviation'] = stats.standard_deviation - stat_dict['maximum'] = stats.maximum - stat_dict['minimum'] = stats.minimum - stat_dict['mean'] = stats.mean - stat_dict['median'] = stats.median + stat_dict['StandardDev'] = stats.standard_deviation + stat_dict['Maximum'] = stats.maximum + stat_dict['Minimum'] = stats.minimum + stat_dict['Mean'] = stats.mean + stat_dict['Median'] = stats.median return stat_dict @@ -42,38 +45,55 @@ class StatisticsOfTableWorkspace(PythonAlgorithm): def PyInit(self): self.declareProperty(ITableWorkspaceProperty('InputWorkspace', '', Direction.Input), doc='Input table workspace.') + validator = IntArrayBoundedValidator() + validator.setLower(0) + self.declareProperty( + IntArrayProperty('ColumnIndices', values=[], direction=Direction.Input, validator=validator), + 'Comma separated list of column indices for which statistics will be separated') self.declareProperty(ITableWorkspaceProperty('OutputWorkspace', '', Direction.Output), - doc='Output workspace contatining column statitics.') + doc='Output workspace containing column statistics.') def PyExec(self): in_ws = mtd[self.getPropertyValue('InputWorkspace')] + indices_list = self.getPropertyValue('ColumnIndices') out_ws_name = self.getPropertyValue('OutputWorkspace') + column_names = in_ws.getColumnNames() + + # If column indices are not provided, then default to _ALL_ columns + if len(indices_list) > 0: + indices_list = [int(x) for x in indices_list.split(',')] + else: + indices_list = range(len(column_names)) out_ws = ms.CreateEmptyTableWorkspace(OutputWorkspace=out_ws_name) - out_ws.addColumn('str', 'statistic') + out_ws.addColumn('str', 'Statistic') stats = collections.OrderedDict([ - ('standard_deviation', collections.OrderedDict()), - ('minimum', collections.OrderedDict()), - ('median', collections.OrderedDict()), - ('maximum', collections.OrderedDict()), - ('mean', collections.OrderedDict()), + ('StandardDev', collections.OrderedDict()), + ('Minimum', collections.OrderedDict()), + ('Median', collections.OrderedDict()), + ('Maximum', collections.OrderedDict()), + ('Mean', collections.OrderedDict()), ]) - for name in in_ws.getColumnNames(): + for index in indices_list: + column_name = column_names[index] try: - col_stats = _stats_to_dict(Stats.getStatistics(np.array([float(v) for v in in_ws.column(name)]))) - for statname in stats: - stats[statname][name] = col_stats[statname] - out_ws.addColumn('float', name) - except ValueError: - logger.notice('Column \'%s\' is not numerical, skipping' % name) - - for name, stat in iteritems(stats): - stat1 = collections.OrderedDict(stat) - stat1['statistic'] = name - out_ws.addRow(stat1) + column_data = np.array([float(v) for v in in_ws.column(index)]) + col_stats = _stats_to_dict(Stats.getStatistics(column_data)) + for stat_name in stats: + stats[stat_name][column_name] = col_stats[stat_name] + out_ws.addColumn('float', column_name) + except RuntimeError: + logger.notice('Column \'%s\' is not numerical, skipping' % column_name) + except: + logger.notice('Column \'%s\' is not numerical, skipping' % column_name) + + for index, stat_name in iteritems(stats): + stat = collections.OrderedDict(stat_name) + stat['Statistic'] = index + out_ws.addRow(stat) self.setProperty('OutputWorkspace', out_ws) diff --git a/Framework/PythonInterface/test/python/plugins/algorithms/StatisticsOfTableWorkspaceTest.py b/Framework/PythonInterface/test/python/plugins/algorithms/StatisticsOfTableWorkspaceTest.py index 4c7aca53c6fa3ac7dbdf420dd880076d8a175668..417f35cb590dc7c7f168c8d7800a00094f164fbd 100644 --- a/Framework/PythonInterface/test/python/plugins/algorithms/StatisticsOfTableWorkspaceTest.py +++ b/Framework/PythonInterface/test/python/plugins/algorithms/StatisticsOfTableWorkspaceTest.py @@ -34,14 +34,13 @@ class StatisticsOfTableWorkspaceTest(unittest.TestCase): self.assertEqual(stats.rowCount(), 5) self.assertEqual(stats.columnCount(), 3) - stat_col = stats.column('statistic') - - self.assertAlmostEqual(stats.column('a')[stat_col.index('standard_deviation')], 1.11803400517) - self.assertAlmostEqual(stats.column('a')[stat_col.index('minimum')], 1.0) - self.assertAlmostEqual(stats.column('a')[stat_col.index('median')], 2.5) - self.assertAlmostEqual(stats.column('a')[stat_col.index('maximum')], 4.0) - self.assertAlmostEqual(stats.column('a')[stat_col.index('mean')], 2.5) + stat_col = stats.column('Statistic') + self.assertAlmostEqual(stats.column('a')[stat_col.index('StandardDev')], 1.11803400517) + self.assertAlmostEqual(stats.column('a')[stat_col.index('Minimum')], 1.0) + self.assertAlmostEqual(stats.column('a')[stat_col.index('Median')], 2.5) + self.assertAlmostEqual(stats.column('a')[stat_col.index('Maximum')], 4.0) + self.assertAlmostEqual(stats.column('a')[stat_col.index('Mean')], 2.5) def test_invalid_types(self): """ diff --git a/buildconfig/CMake/Bootstrap.cmake b/buildconfig/CMake/Bootstrap.cmake index 1272f8f8ee299019e1906b73bd1fcb9368a6a7c0..7f0d1195b812d95221c2476e424def5ed590183c 100644 --- a/buildconfig/CMake/Bootstrap.cmake +++ b/buildconfig/CMake/Bootstrap.cmake @@ -10,7 +10,7 @@ if( MSVC ) include ( ExternalProject ) set( EXTERNAL_ROOT ${PROJECT_SOURCE_DIR}/external CACHE PATH "Location to clone third party dependencies to" ) set( THIRD_PARTY_GIT_URL "https://github.com/mantidproject/thirdparty-msvc2015.git" ) - set ( THIRD_PARTY_GIT_SHA1 80b72f1c5ad30675c03967ead71c8223fbe50d4f ) + set ( THIRD_PARTY_GIT_SHA1 22bf373a4055d85b6793a34f6c823719d482a229 ) set ( THIRD_PARTY_DIR ${EXTERNAL_ROOT}/src/ThirdParty ) # Generates a script to do the clone/update in tmp set ( _project_name ThirdParty ) diff --git a/docs/source/algorithms/StatisticsOfTableWorkspace-v1.rst b/docs/source/algorithms/StatisticsOfTableWorkspace-v1.rst index c53256425cb77e93e359f54406064e57e344bf98..70c3c34a4b1ffca081795ca28bc76386a78e7217 100644 --- a/docs/source/algorithms/StatisticsOfTableWorkspace-v1.rst +++ b/docs/source/algorithms/StatisticsOfTableWorkspace-v1.rst @@ -30,7 +30,7 @@ Usage stats = StatisticsOfTableWorkspace(ws) for idx in range(stats.rowCount()): - stat_name = stats.column('statistic')[idx] + stat_name = stats.column('Statistic')[idx] stat_value = stats.column('a')[idx] print('%s of column \'a\' is %.3f' % (stat_name, stat_value)) @@ -38,11 +38,11 @@ Output: .. testoutput:: ExStatisticsOfTableWorkspace - standard_deviation of column 'a' is 1.118 - minimum of column 'a' is 1.000 - median of column 'a' is 2.500 - maximum of column 'a' is 4.000 - mean of column 'a' is 2.500 + StandardDev of column 'a' is 1.118 + Minimum of column 'a' is 1.000 + Median of column 'a' is 2.500 + Maximum of column 'a' is 4.000 + Mean of column 'a' is 2.500 .. categories:: diff --git a/qt/applications/workbench/workbench/plugins/workspacewidget.py b/qt/applications/workbench/workbench/plugins/workspacewidget.py index 89d5a9038663d0ad29ee78c0e7c39aa8187bd6bb..f4a5623af11a21bb07b6b12e0a25dd0f8cd9edc9 100644 --- a/qt/applications/workbench/workbench/plugins/workspacewidget.py +++ b/qt/applications/workbench/workbench/plugins/workspacewidget.py @@ -12,17 +12,20 @@ from __future__ import (absolute_import, unicode_literals) # system imports from functools import partial +import matplotlib.pyplot +from qtpy.QtWidgets import QMessageBox, QVBoxLayout + # third-party library imports from mantid.api import AnalysisDataService +from mantid.kernel import logger +from mantidqt.widgets.instrumentview.presenter import InstrumentViewPresenter from mantidqt.widgets.matrixworkspacedisplay.presenter import MatrixWorkspaceDisplay from mantidqt.widgets.samplelogs.presenter import SampleLogs -from mantidqt.widgets.instrumentview.presenter import InstrumentViewPresenter +from mantidqt.widgets.tableworkspacedisplay.presenter import TableWorkspaceDisplay from mantidqt.widgets.workspacewidget.workspacetreewidget import WorkspaceTreeWidget -from qtpy.QtWidgets import QMessageBox, QVBoxLayout - +from workbench.plotting.functions import can_overplot, pcolormesh, plot, plot_from_names # local package imports from workbench.plugins.base import PluginWidget -from workbench.plotting.functions import can_overplot, pcolormesh, plot_from_names, plot class WorkspaceWidget(PluginWidget): @@ -121,10 +124,20 @@ class WorkspaceWidget(PluginWidget): def _do_show_data(self, names): for ws in self._ads.retrieveWorkspaces(names, unrollGroups=True): - # the plot function is being injected in the presenter - # this is done so that the plotting library is mockable in testing - presenter = MatrixWorkspaceDisplay(ws, plot=plot, parent=self) - presenter.view.show() + try: + MatrixWorkspaceDisplay.supports(ws) + # the plot function is being injected in the presenter + # this is done so that the plotting library is mockable in testing + presenter = MatrixWorkspaceDisplay(ws, plot=plot, parent=self) + presenter.view.show() + except ValueError: + try: + TableWorkspaceDisplay.supports(ws) + presenter = TableWorkspaceDisplay(ws, plot=matplotlib.pyplot, parent=self) + presenter.view.show() + except ValueError: + logger.error( + "Could not open workspace: {0} with either MatrixWorkspaceDisplay nor TableWorkspaceDisplay.") def _action_double_click_workspace(self, name): self._do_show_data([name]) diff --git a/qt/python/CMakeLists.txt b/qt/python/CMakeLists.txt index b38517290693dffdc9c9369c38b3292426927278..ab3eca260cd2689b6a94954aa82743ece084585b 100644 --- a/qt/python/CMakeLists.txt +++ b/qt/python/CMakeLists.txt @@ -98,6 +98,13 @@ endif () mantidqt/widgets/matrixworkspacedisplay/test/test_matrixworkspacedisplay_model.py mantidqt/widgets/matrixworkspacedisplay/test/test_matrixworkspacedisplay_presenter.py mantidqt/widgets/matrixworkspacedisplay/test/test_matrixworkspacedisplay_tableviewmodel.py + + mantidqt/widgets/tableworkspacedisplay/test/test_tableworkspacedisplay_error_column.py + mantidqt/widgets/tableworkspacedisplay/test/test_tableworkspacedisplay_marked_columns.py + mantidqt/widgets/tableworkspacedisplay/test/test_tableworkspacedisplay_workbench_table_widget_item.py + + mantidqt/widgets/tableworkspacedisplay/test/test_tableworkspacedisplay_model.py + mantidqt/widgets/tableworkspacedisplay/test/test_tableworkspacedisplay_presenter.py ) # Tests diff --git a/qt/python/mantidqt/widgets/common/__init__.py b/qt/python/mantidqt/widgets/common/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/qt/python/mantidqt/widgets/common/table_copying.py b/qt/python/mantidqt/widgets/common/table_copying.py new file mode 100644 index 0000000000000000000000000000000000000000..8eb2f0587e6f3584749672ed736b87b910da8a38 --- /dev/null +++ b/qt/python/mantidqt/widgets/common/table_copying.py @@ -0,0 +1,156 @@ +from __future__ import (absolute_import, division, print_function) + +from qtpy import QtGui +from qtpy.QtCore import QPoint +from qtpy.QtGui import QCursor, QFont, QFontMetrics +from qtpy.QtWidgets import (QMessageBox, QToolTip) + +NO_SELECTION_MESSAGE = "No selection" +COPY_SUCCESSFUL_MESSAGE = "Copy Successful" + +""" +This module contains the common copying functionality between +the MatrixWorkspaceDisplay and the TableWorkspaceDisplay. +""" + + +def copy_spectrum_values(table, ws_read): + """ + Copies the values selected by the user to the system's clipboard + + :param table: Table from which the selection will be read + :param ws_read: The workspace read function, that is used to access the data directly + """ + selection_model = table.selectionModel() + if not selection_model.hasSelection(): + show_no_selection_to_copy_toast() + return + selected_rows = selection_model.selectedRows() # type: list + row_data = [] + + for index in selected_rows: + row = index.row() + data = "\t".join(map(str, ws_read(row))) + + row_data.append(data) + + copy_to_clipboard("\n".join(row_data)) + show_successful_copy_toast() + + +def copy_bin_values(table, ws_read, num_rows): + """ + Copies the values selected by the user to the system's clipboard + + :param table: Table from which the selection will be read + :param ws_read: The workspace read function, that is used to access the data directly + :param num_rows: The number of rows in the column + """ + selection_model = table.selectionModel() + if not selection_model.hasSelection(): + show_no_selection_to_copy_toast() + return + selected_columns = selection_model.selectedColumns() # type: list + + # Qt gives back a QModelIndex, we need to extract the column from it + column_data = [] + for index in selected_columns: + column = index.column() + data = [str(ws_read(row)[column]) for row in range(num_rows)] + column_data.append(data) + + all_string_rows = [] + for i in range(num_rows): + # Appends ONE value from each COLUMN, this is because the final string is being built vertically + # the noqa disables a 'data' variable redefined warning + all_string_rows.append("\t".join([data[i] for data in column_data])) # noqa: F812 + + # Finally all rows are joined together with a new line at the end of each row + final_string = "\n".join(all_string_rows) + copy_to_clipboard(final_string) + show_successful_copy_toast() + + +def copy_cells(table): + """ + :type table: QTableView + :param table: The table from which the data will be copied. + :return: + """ + selectionModel = table.selectionModel() + if not selectionModel.hasSelection(): + show_no_selection_to_copy_toast() + return + + selection = selectionModel.selection() + selectionRange = selection.first() + + top = selectionRange.top() + bottom = selectionRange.bottom() + left = selectionRange.left() + right = selectionRange.right() + + data = [] + index = selectionModel.currentIndex() + for i in range(top, bottom + 1): + for j in range(left, right): + data.append(str(index.sibling(i, j).data())) + data.append("\t") + data.append(str(index.sibling(i, right).data())) + data.append("\n") + + # strip the string to remove the trailing new line + copy_to_clipboard("".join(data).strip()) + show_successful_copy_toast() + + +def keypress_copy(table, view, ws_read, num_rows): + selectionModel = table.selectionModel() + if not selectionModel.hasSelection(): + show_no_selection_to_copy_toast() + return + + if len(selectionModel.selectedRows()) > 0: + copy_spectrum_values(table, ws_read) + elif len(selectionModel.selectedColumns()) > 0: + copy_bin_values(table, ws_read, num_rows) + else: + copy_cells(table) + + +def show_mouse_toast(message): + # Creates a text with empty space to get the height of the rendered text - this is used + # to provide the same offset for the tooltip, scaled relative to the current resolution and zoom. + font_metrics = QFontMetrics(QFont(" ")) + # The height itself is divided by 2 just to reduce the offset so that the tooltip is + # reasonably position relative to the cursor + QToolTip.showText(QCursor.pos() + QPoint(font_metrics.height() / 2, 0), message) + + +def copy_to_clipboard(data): + """ + Uses the QGuiApplication to copy to the system clipboard. + + :type data: str + :param data: The data that will be copied to the clipboard + :return: + """ + cb = QtGui.QGuiApplication.clipboard() + cb.setText(data, mode=cb.Clipboard) + + +def ask_confirmation(self, message, title="Mantid Workbench"): + """ + :param message: + :return: + """ + reply = QMessageBox.question(self, title, message, QMessageBox.Yes, QMessageBox.No) + return True if reply == QMessageBox.Yes else False + + +def show_no_selection_to_copy_toast(): + show_mouse_toast(NO_SELECTION_MESSAGE) + + +def show_successful_copy_toast(): + show_mouse_toast(COPY_SUCCESSFUL_MESSAGE) diff --git a/qt/python/mantidqt/widgets/matrixworkspacedisplay/model.py b/qt/python/mantidqt/widgets/matrixworkspacedisplay/model.py index 1f16359607aa526dfbeff66ef5e85f67fb4ece02..8afc6d1b6af9499691bba176ff693985619ff779 100644 --- a/qt/python/mantidqt/widgets/matrixworkspacedisplay/model.py +++ b/qt/python/mantidqt/widgets/matrixworkspacedisplay/model.py @@ -22,10 +22,23 @@ class MatrixWorkspaceDisplayModel(object): ALLOWED_WORKSPACE_TYPES = [MatrixWorkspace, Workspace2D, EventWorkspace] - def __init__(self, ws): - if not any(isinstance(ws, allowed_type) for allowed_type in self.ALLOWED_WORKSPACE_TYPES): + @classmethod + def supports(cls, ws): + """ + Checks that the provided workspace is supported by this display. + :param ws: Workspace to be checked for support + :raises ValueError: if the workspace is not supported + """ + if not any(isinstance(ws, allowed_type) for allowed_type in cls.ALLOWED_WORKSPACE_TYPES): raise ValueError("The workspace type is not supported: {0}".format(ws)) + def __init__(self, ws): + """ + Initialise the model with the workspace + :param ws: Workspace to be used for providing data + :raises ValueError: if the workspace is not supported + """ + self.supports(ws) self._ws = ws def get_name(self): diff --git a/qt/python/mantidqt/widgets/matrixworkspacedisplay/presenter.py b/qt/python/mantidqt/widgets/matrixworkspacedisplay/presenter.py index 7e6457a13f2cd146edb53bddece003bcde5a3210..e01fb1768b4761af792617dc9daa73c9b438ffd5 100644 --- a/qt/python/mantidqt/widgets/matrixworkspacedisplay/presenter.py +++ b/qt/python/mantidqt/widgets/matrixworkspacedisplay/presenter.py @@ -10,6 +10,8 @@ from __future__ import absolute_import, division, print_function from mantid.plots.utility import MantidAxType +from mantidqt.widgets.common.table_copying import copy_bin_values, copy_cells, copy_spectrum_values, \ + show_no_selection_to_copy_toast from mantidqt.widgets.matrixworkspacedisplay.table_view_model import MatrixWorkspaceTableViewModelType from .model import MatrixWorkspaceDisplayModel from .view import MatrixWorkspaceDisplayView @@ -22,6 +24,15 @@ class MatrixWorkspaceDisplay(object): NUM_SELECTED_FOR_CONFIRMATION = 10 def __init__(self, ws, plot=None, parent=None, model=None, view=None): + """ + Creates a display for the provided workspace. + + :param ws: Workspace to be displayed + :param plot: Plotting function that will be used to plot workspaces. Passed in as parameter to allow mocking + :param parent: Parent of the widget + :param model: Model to be used by the widget. Passed in as parameter to allow mocking + :param view: View to be used by the widget. Passed in as parameter to allow mocking + """ # Create model and view, or accept mocked versions self.model = model if model else MatrixWorkspaceDisplayModel(ws) self.view = view if view else MatrixWorkspaceDisplayView(self, @@ -35,105 +46,37 @@ class MatrixWorkspaceDisplay(object): self.view.set_context_menu_actions(self.view.table_x) self.view.set_context_menu_actions(self.view.table_e) + @classmethod + def supports(cls, ws): + """ + Checks that the provided workspace is supported by this display. + :param ws: Workspace to be checked for support + :raises ValueError: if the workspace is not supported + """ + return MatrixWorkspaceDisplayModel.supports(ws) + def setup_tables(self): # unpacks the list of models returned from getItemModel self.view.set_model(*self.model.get_item_model()) def action_copy_spectrum_values(self, table): - """ - Copies the values selected by the user to the system's clipboard - - :param table: Table from which the selection will be read - :param ws_read: The workspace read function, that is used to access the data directly - """ - selection_model = table.selectionModel() - if not selection_model.hasSelection(): - self.show_no_selection_to_copy_toast() - return ws_read = self._get_ws_read_from_type(table.model().type) - selected_rows = selection_model.selectedRows() # type: list - row_data = [] - - for index in selected_rows: - row = index.row() - data = "\t".join(map(str, ws_read(row))) - - row_data.append(data) - - self.view.copy_to_clipboard("\n".join(row_data)) - self.show_successful_copy_toast() - - def show_no_selection_to_copy_toast(self): - self.view.show_mouse_toast(self.NO_SELECTION_MESSAGE) - - def show_successful_copy_toast(self): - self.view.show_mouse_toast(self.COPY_SUCCESSFUL_MESSAGE) + copy_spectrum_values(table, ws_read) def action_copy_bin_values(self, table): - selection_model = table.selectionModel() - if not selection_model.hasSelection(): - self.show_no_selection_to_copy_toast() - return ws_read = self._get_ws_read_from_type(table.model().type) - selected_columns = selection_model.selectedColumns() # type: list - - # Qt gives back a QModelIndex, we need to extract the column from it num_rows = self.model._ws.getNumberHistograms() - column_data = [] - for index in selected_columns: - column = index.column() - data = [str(ws_read(row)[column]) for row in range(num_rows)] - column_data.append(data) - - all_string_rows = [] - for i in range(num_rows): - # Appends ONE value from each COLUMN, this is because the final string is being built vertically - # the noqa disables a 'data' variable redefined warning - all_string_rows.append("\t".join([data[i] for data in column_data])) # noqa: F812 - - # Finally all rows are joined together with a new line at the end of each row - final_string = "\n".join(all_string_rows) - self.view.copy_to_clipboard(final_string) - self.show_successful_copy_toast() + copy_bin_values(table, ws_read, num_rows) def action_copy_cells(self, table): - """ - :type table: QTableView - :param table: The table from which the data will be copied. - :return: - """ - selectionModel = table.selectionModel() - if not selectionModel.hasSelection(): - self.show_no_selection_to_copy_toast() - return - - selection = selectionModel.selection() - selectionRange = selection.first() - - top = selectionRange.top() - bottom = selectionRange.bottom() - left = selectionRange.left() - right = selectionRange.right() - - data = [] - index = selectionModel.currentIndex() - for i in range(top, bottom + 1): - for j in range(left, right): - data.append(index.sibling(i, j).data()) - data.append("\t") - data.append(index.sibling(i, right).data()) - data.append("\n") - - # strip the string to remove the trailing new line - self.view.copy_to_clipboard("".join(data).strip()) - self.show_successful_copy_toast() + copy_cells(table) def _do_action_plot(self, table, axis, get_index, plot_errors=False): if self.plot is None: raise ValueError("Trying to do a plot, but no plotting class dependency was injected in the constructor") selection_model = table.selectionModel() if not selection_model.hasSelection(): - self.show_no_selection_to_copy_toast() + show_no_selection_to_copy_toast() return if axis == MantidAxType.SPECTRUM: @@ -167,7 +110,7 @@ class MatrixWorkspaceDisplay(object): def action_keypress_copy(self, table): selectionModel = table.selectionModel() if not selectionModel.hasSelection(): - self.show_no_selection_to_copy_toast() + show_no_selection_to_copy_toast() return if len(selectionModel.selectedRows()) > 0: diff --git a/qt/python/mantidqt/widgets/matrixworkspacedisplay/test/test_matrixworkspacedisplay_presenter.py b/qt/python/mantidqt/widgets/matrixworkspacedisplay/test/test_matrixworkspacedisplay_presenter.py index 88f40e77954088a5311ff7e101a1f5f2d057e0a9..378dc5956c3868ef52eb8320a9c55b956d8ff7b2 100644 --- a/qt/python/mantidqt/widgets/matrixworkspacedisplay/test/test_matrixworkspacedisplay_presenter.py +++ b/qt/python/mantidqt/widgets/matrixworkspacedisplay/test/test_matrixworkspacedisplay_presenter.py @@ -11,7 +11,7 @@ from __future__ import (absolute_import, division, print_function) import unittest -from mock import Mock +from mock import Mock, patch from mantidqt.widgets.matrixworkspacedisplay.model import MatrixWorkspaceDisplayModel from mantidqt.widgets.matrixworkspacedisplay.presenter import MatrixWorkspaceDisplay @@ -37,7 +37,9 @@ class MatrixWorkspaceDisplayPresenterTest(unittest.TestCase): self.assertEqual(3, view.set_context_menu_actions.call_count) self.assertEqual(1, view.set_model.call_count) - def test_action_copy_spectrum_values(self): + @patch('mantidqt.widgets.common.table_copying.show_mouse_toast') + @patch('mantidqt.widgets.common.table_copying.copy_to_clipboard') + def test_action_copy_spectrum_values(self, mock_copy, mock_show_mouse_toast): ws = MockWorkspace() view = MockMatrixWorkspaceDisplayView() presenter = MatrixWorkspaceDisplay(ws, view=view) @@ -56,10 +58,12 @@ class MatrixWorkspaceDisplayPresenterTest(unittest.TestCase): mock_table.selectionModel.assert_called_once_with() mock_table.mock_selection_model.hasSelection.assert_called_once_with() - view.copy_to_clipboard.assert_called_once_with(expected_string) - view.show_mouse_toast.assert_called_once_with(MatrixWorkspaceDisplay.COPY_SUCCESSFUL_MESSAGE) + mock_copy.assert_called_once_with(expected_string) + mock_show_mouse_toast.assert_called_once_with(MatrixWorkspaceDisplay.COPY_SUCCESSFUL_MESSAGE) - def test_action_copy_spectrum_values_no_selection(self): + @patch('mantidqt.widgets.common.table_copying.show_mouse_toast') + @patch('mantidqt.widgets.common.table_copying.copy_to_clipboard') + def test_action_copy_spectrum_values_no_selection(self, mock_copy, mock_show_mouse_toast): ws = MockWorkspace() view = MockMatrixWorkspaceDisplayView() presenter = MatrixWorkspaceDisplay(ws, view=view) @@ -74,9 +78,12 @@ class MatrixWorkspaceDisplayPresenterTest(unittest.TestCase): mock_table.mock_selection_model.hasSelection.assert_called_once_with() # the action should never look for rows if there is no selection self.assertNotCalled(mock_table.mock_selection_model.selectedRows) - view.show_mouse_toast.assert_called_once_with(MatrixWorkspaceDisplay.NO_SELECTION_MESSAGE) + self.assertNotCalled(mock_copy) + mock_show_mouse_toast.assert_called_once_with(MatrixWorkspaceDisplay.NO_SELECTION_MESSAGE) - def test_action_copy_bin_values(self): + @patch('mantidqt.widgets.common.table_copying.show_mouse_toast') + @patch('mantidqt.widgets.common.table_copying.copy_to_clipboard') + def test_action_copy_bin_values(self, mock_copy, mock_show_mouse_toast): ws = MockWorkspace() view = MockMatrixWorkspaceDisplayView() presenter = MatrixWorkspaceDisplay(ws, view=view) @@ -95,11 +102,13 @@ class MatrixWorkspaceDisplayPresenterTest(unittest.TestCase): presenter.action_copy_bin_values(mock_table) mock_table.selectionModel.assert_called_once_with() - view.copy_to_clipboard.assert_called_once_with(expected_string) mock_table.mock_selection_model.hasSelection.assert_called_once_with() - view.show_mouse_toast.assert_called_once_with(MatrixWorkspaceDisplay.COPY_SUCCESSFUL_MESSAGE) + mock_copy.assert_called_once_with(expected_string) + mock_show_mouse_toast.assert_called_once_with(MatrixWorkspaceDisplay.COPY_SUCCESSFUL_MESSAGE) - def test_action_copy_bin_values_no_selection(self): + @patch('mantidqt.widgets.common.table_copying.show_mouse_toast') + @patch('mantidqt.widgets.common.table_copying.copy_to_clipboard') + def test_action_copy_bin_values_no_selection(self, mock_copy, mock_show_mouse_toast): ws = MockWorkspace() view = MockMatrixWorkspaceDisplayView() presenter = MatrixWorkspaceDisplay(ws, view=view) @@ -114,9 +123,12 @@ class MatrixWorkspaceDisplayPresenterTest(unittest.TestCase): mock_table.mock_selection_model.hasSelection.assert_called_once_with() # the action should never look for rows if there is no selection self.assertNotCalled(mock_table.mock_selection_model.selectedColumns) - view.show_mouse_toast.assert_called_once_with(MatrixWorkspaceDisplay.NO_SELECTION_MESSAGE) + self.assertNotCalled(mock_copy) + mock_show_mouse_toast.assert_called_once_with(MatrixWorkspaceDisplay.NO_SELECTION_MESSAGE) - def test_action_copy_cell(self): + @patch('mantidqt.widgets.common.table_copying.show_mouse_toast') + @patch('mantidqt.widgets.common.table_copying.copy_to_clipboard') + def test_action_copy_cell(self, mock_copy, mock_show_mouse_toast): ws = MockWorkspace() view = MockMatrixWorkspaceDisplayView() presenter = MatrixWorkspaceDisplay(ws, view=view) @@ -129,11 +141,13 @@ class MatrixWorkspaceDisplayPresenterTest(unittest.TestCase): presenter.action_copy_cells(mock_table) mock_table.selectionModel.assert_called_once_with() - self.assertEqual(1, view.copy_to_clipboard.call_count) + self.assertEqual(1, mock_copy.call_count) self.assertEqual(9, mock_index.sibling.call_count) - view.show_mouse_toast.assert_called_once_with(MatrixWorkspaceDisplay.COPY_SUCCESSFUL_MESSAGE) + mock_show_mouse_toast.assert_called_once_with(MatrixWorkspaceDisplay.COPY_SUCCESSFUL_MESSAGE) - def test_action_copy_cell_no_selection(self): + @patch('mantidqt.widgets.common.table_copying.show_mouse_toast') + @patch('mantidqt.widgets.common.table_copying.copy_to_clipboard') + def test_action_copy_cell_no_selection(self, mock_copy, mock_show_mouse_toast): ws = MockWorkspace() view = MockMatrixWorkspaceDisplayView() presenter = MatrixWorkspaceDisplay(ws, view=view) @@ -144,9 +158,9 @@ class MatrixWorkspaceDisplayPresenterTest(unittest.TestCase): mock_table.selectionModel.assert_called_once_with() mock_table.mock_selection_model.hasSelection.assert_called_once_with() - view.show_mouse_toast.assert_called_once_with(MatrixWorkspaceDisplay.NO_SELECTION_MESSAGE) + mock_show_mouse_toast.assert_called_once_with(MatrixWorkspaceDisplay.NO_SELECTION_MESSAGE) - self.assertNotCalled(view.copy_to_clipboard) + self.assertNotCalled(mock_copy) def common_setup_action_plot(self, table_has_selection=True): mock_ws = MockWorkspace() @@ -230,7 +244,8 @@ class MatrixWorkspaceDisplayPresenterTest(unittest.TestCase): self.assertNotCalled(mock_table.mock_selection_model.selectedColumns) self.assertNotCalled(mock_plot) - def test_action_plot_spectrum_no_selection(self): + @patch('mantidqt.widgets.common.table_copying.show_mouse_toast') + def test_action_plot_spectrum_no_selection(self, mock_show_mouse_toast): mock_plot, mock_table, mock_view, presenter = self.common_setup_action_plot(table_has_selection=False) mock_table.mock_selection_model.selectedRows = Mock() @@ -238,7 +253,7 @@ class MatrixWorkspaceDisplayPresenterTest(unittest.TestCase): presenter.action_plot_spectrum(mock_table) - mock_view.show_mouse_toast.assert_called_once_with(MatrixWorkspaceDisplay.NO_SELECTION_MESSAGE) + mock_show_mouse_toast.assert_called_once_with(MatrixWorkspaceDisplay.NO_SELECTION_MESSAGE) mock_table.selectionModel.assert_called_once_with() mock_table.mock_selection_model.hasSelection.assert_called_once_with() @@ -284,13 +299,14 @@ class MatrixWorkspaceDisplayPresenterTest(unittest.TestCase): self.assertNotCalled(mock_table.mock_selection_model.selectedRows) self.assertNotCalled(mock_plot) - def test_action_plot_bin_no_selection(self): + @patch('mantidqt.widgets.common.table_copying.show_mouse_toast') + def test_action_plot_bin_no_selection(self, mock_show_mouse_toast): mock_plot, mock_table, mock_view, presenter = self.common_setup_action_plot(table_has_selection=False) self.setup_mock_selection(mock_table, num_selected_rows=None, num_selected_cols=None) presenter.action_plot_bin(mock_table) - mock_view.show_mouse_toast.assert_called_once_with(MatrixWorkspaceDisplay.NO_SELECTION_MESSAGE) + mock_show_mouse_toast.assert_called_once_with(MatrixWorkspaceDisplay.NO_SELECTION_MESSAGE) mock_table.selectionModel.assert_called_once_with() mock_table.mock_selection_model.hasSelection.assert_called_once_with() diff --git a/qt/python/mantidqt/widgets/matrixworkspacedisplay/test_helpers/matrixworkspacedisplay_common.py b/qt/python/mantidqt/widgets/matrixworkspacedisplay/test_helpers/matrixworkspacedisplay_common.py index 30cc8e48c8d10b13489984c2edb7137a233bacd2..66d406bc55df5213b3bd2f17cbb0b18f7e3d4b4e 100644 --- a/qt/python/mantidqt/widgets/matrixworkspacedisplay/test_helpers/matrixworkspacedisplay_common.py +++ b/qt/python/mantidqt/widgets/matrixworkspacedisplay/test_helpers/matrixworkspacedisplay_common.py @@ -125,10 +125,27 @@ class MockWorkspace: self.maskedBinsIndices = None self.isCommonBins = Mock(return_value=True) + self.column_types = ["int", "float", "string", "v3d", "bool"] + self.columnTypes = Mock(return_value=self.column_types) + self.mock_spectrum = MockSpectrum() self.getSpectrum = Mock(return_value=self.mock_spectrum) self.mock_axis = MockMantidAxis() self.getAxis = Mock(return_value=self.mock_axis) + self.setCell = Mock() + self.name = None + + def rowCount(self): + pass + + def columnCount(self): + pass + + def getColumnNames(self): + pass + + def column(self, index): + pass diff --git a/qt/python/mantidqt/widgets/matrixworkspacedisplay/test_helpers/mock_matrixworkspacedisplay.py b/qt/python/mantidqt/widgets/matrixworkspacedisplay/test_helpers/mock_matrixworkspacedisplay.py index 4b67e28ccaecd7eb454a28738204067ad12ab743..d3856441e64e699b6baa7fc2adce46d80aff1600 100644 --- a/qt/python/mantidqt/widgets/matrixworkspacedisplay/test_helpers/mock_matrixworkspacedisplay.py +++ b/qt/python/mantidqt/widgets/matrixworkspacedisplay/test_helpers/mock_matrixworkspacedisplay.py @@ -70,8 +70,6 @@ class MockMatrixWorkspaceDisplayView: self.table_y = MockQTableView() self.table_e = MockQTableView() self.set_model = Mock() - self.copy_to_clipboard = Mock() - self.show_mouse_toast = Mock() self.ask_confirmation = None diff --git a/qt/python/mantidqt/widgets/matrixworkspacedisplay/view.py b/qt/python/mantidqt/widgets/matrixworkspacedisplay/view.py index 2c123a1b13c4546338e2de61a8135feb974d9366..db6be8b0dbaa5dd255ad8a864893f3888eb106a6 100644 --- a/qt/python/mantidqt/widgets/matrixworkspacedisplay/view.py +++ b/qt/python/mantidqt/widgets/matrixworkspacedisplay/view.py @@ -12,9 +12,9 @@ from __future__ import (absolute_import, division, print_function) from functools import partial from qtpy import QtGui -from qtpy.QtCore import QPoint, Qt -from qtpy.QtGui import QCursor, QFont, QFontMetrics, QKeySequence -from qtpy.QtWidgets import (QAbstractItemView, QAction, QHeaderView, QMessageBox, QTabWidget, QTableView, QToolTip) +from qtpy.QtCore import Qt +from qtpy.QtGui import QKeySequence +from qtpy.QtWidgets import (QAbstractItemView, QAction, QHeaderView, QMessageBox, QTabWidget, QTableView) import mantidqt.icons from mantidqt.widgets.matrixworkspacedisplay.table_view_model import MatrixWorkspaceTableViewModelType @@ -144,26 +144,6 @@ class MatrixWorkspaceDisplayView(QTabWidget): model.model_type) table.setModel(model) - @staticmethod - def copy_to_clipboard(data): - """ - Uses the QGuiApplication to copy to the system clipboard. - - :type data: str - :param data: The data that will be copied to the clipboard - :return: - """ - cb = QtGui.QGuiApplication.clipboard() - cb.setText(data, mode=cb.Clipboard) - - def show_mouse_toast(self, message): - # Creates a text with empty space to get the height of the rendered text - this is used - # to provide the same offset for the tooltip, scaled relative to the current resolution and zoom. - font_metrics = QFontMetrics(QFont(" ")) - # The height itself is divided by 2 just to reduce the offset so that the tooltip is - # reasonably position relative to the cursor - QToolTip.showText(QCursor.pos() + QPoint(font_metrics.height() / 2, 0), message) - def ask_confirmation(self, message, title="Mantid Workbench"): """ :param message: diff --git a/qt/python/mantidqt/widgets/tableworkspacedisplay/__init__.py b/qt/python/mantidqt/widgets/tableworkspacedisplay/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..57d5ae5a28a63ed0dd44886f201c25df7cac61ca --- /dev/null +++ b/qt/python/mantidqt/widgets/tableworkspacedisplay/__init__.py @@ -0,0 +1,9 @@ +# Mantid Repository : https://github.com/mantidproject/mantid +# +# Copyright © 2018 ISIS Rutherford Appleton Laboratory UKRI, +# NScD Oak Ridge National Laboratory, European Spallation Source +# & Institut Laue - Langevin +# SPDX - License - Identifier: GPL - 3.0 + +# This file is part of the mantid workbench. +# +# diff --git a/qt/python/mantidqt/widgets/tableworkspacedisplay/__main__.py b/qt/python/mantidqt/widgets/tableworkspacedisplay/__main__.py new file mode 100644 index 0000000000000000000000000000000000000000..7d11d49143c9e97cd34e51493e4c3eb13ce47d3b --- /dev/null +++ b/qt/python/mantidqt/widgets/tableworkspacedisplay/__main__.py @@ -0,0 +1,27 @@ +# Mantid Repository : https://github.com/mantidproject/mantid +# +# Copyright © 2018 ISIS Rutherford Appleton Laboratory UKRI, +# NScD Oak Ridge National Laboratory, European Spallation Source +# & Institut Laue - Langevin +# SPDX - License - Identifier: GPL - 3.0 + +# This file is part of the mantid workbench. +# +# + +# To Run - target this package with PyCharm, and __main__ will be executed + +import matplotlib + +matplotlib.use('Qt5Agg') + + +from qtpy.QtWidgets import QApplication # noqa: F402 + +from mantid.simpleapi import Load # noqa: F402 +from mantidqt.widgets.tableworkspacedisplay.presenter import TableWorkspaceDisplay # noqa: F402 +import matplotlib.pyplot as plt # noqa: F402 + +app = QApplication([]) +ws = Load("SavedTableWorkspace.nxs") +window = TableWorkspaceDisplay(ws, plt) +app.exec_() diff --git a/qt/python/mantidqt/widgets/tableworkspacedisplay/error_column.py b/qt/python/mantidqt/widgets/tableworkspacedisplay/error_column.py new file mode 100644 index 0000000000000000000000000000000000000000..481a8b98b75cff82ec00b75e08365b8b0d5607d6 --- /dev/null +++ b/qt/python/mantidqt/widgets/tableworkspacedisplay/error_column.py @@ -0,0 +1,35 @@ +# Mantid Repository : https://github.com/mantidproject/mantid +# +# Copyright © 2019 ISIS Rutherford Appleton Laboratory UKRI, +# NScD Oak Ridge National Laboratory, European Spallation Source +# & Institut Laue - Langevin +# SPDX - License - Identifier: GPL - 3.0 + +# This file is part of the mantid workbench. +# +# + + +class ErrorColumn: + def __init__(self, column, error_for_column, label_index): + self.column = column + self.error_for_column = error_for_column + if self.column == self.error_for_column: + raise ValueError("Cannot set Y column to be its own YErr") + + self.label_index = label_index + + def __eq__(self, other): + if isinstance(other, ErrorColumn): + return self.error_for_column == other.error_for_column or self.column == other.column + elif isinstance(other, int): + return self.column == other + else: + raise RuntimeError("Unhandled comparison logic with type {}".format(type(other))) + + def __cmp__(self, other): + if isinstance(other, ErrorColumn): + return self.column == other.column or self.error_for_column == other.error_for_column + elif isinstance(other, int): + return self.column == other + else: + raise RuntimeError("Unhandled comparison logic with type {}".format(type(other))) diff --git a/qt/python/mantidqt/widgets/tableworkspacedisplay/marked_columns.py b/qt/python/mantidqt/widgets/tableworkspacedisplay/marked_columns.py new file mode 100644 index 0000000000000000000000000000000000000000..8c30c53d3e2fdd01d721a199cefed8c6d27b4493 --- /dev/null +++ b/qt/python/mantidqt/widgets/tableworkspacedisplay/marked_columns.py @@ -0,0 +1,111 @@ +# Mantid Repository : https://github.com/mantidproject/mantid +# +# Copyright © 2019 ISIS Rutherford Appleton Laboratory UKRI, +# NScD Oak Ridge National Laboratory, European Spallation Source +# & Institut Laue - Langevin +# SPDX - License - Identifier: GPL - 3.0 + +# This file is part of the mantid workbench. +# +# + + +class MarkedColumns: + X_LABEL = "[X{}]" + Y_LABEL = "[Y{}]" + Y_ERR_LABEL = "[Y{}_YErr]" + + def __init__(self): + self.as_x = [] + self.as_y = [] + self.as_y_err = [] + + def _add(self, col_index, add_to, remove_from): + assert all( + add_to is not remove for remove in remove_from), "Can't add and remove from the same list at the same time!" + self._remove(col_index, remove_from) + + if col_index not in add_to: + add_to.append(col_index) + + def _remove(self, col_index, remove_from): + """ + Remove the column index from all lists + :param col_index: The column index to be removed + :type remove_from: list[list[Union[int, ErrorColumn]]] + :param remove_from: List of lists from which the column index will be removed + :return: + """ + for list in remove_from: + try: + list.remove(col_index) + except ValueError: + # column not in this list, but might be in another one so we continue the loop + continue + + # if the column previously had a Y Err associated with it -> this will remove it from the YErr list + self._remove_associated_yerr_columns(col_index) + + def add_x(self, col_index): + self._add(col_index, self.as_x, [self.as_y, self.as_y_err]) + + def add_y(self, col_index): + self._add(col_index, self.as_y, [self.as_x, self.as_y_err]) + + def add_y_err(self, err_column): + if err_column.error_for_column in self.as_x: + raise ValueError("Trying to add YErr for column marked as X.") + elif err_column.error_for_column in self.as_y_err: + raise ValueError("Trying to add YErr for column marked as YErr.") + # remove all labels for the column index + len_before_remove = len(self.as_y) + self._remove(err_column, [self.as_x, self.as_y, self.as_y_err]) + + # Check if the length of the list with columns marked Y has shrunk + # -> This means that columns have been removed, and the label_index is now _wrong_ + # and has to be decremented to match the new label index correctly + len_after_remove = len(self.as_y) + if err_column.error_for_column > err_column.column and len_after_remove < len_before_remove: + err_column.label_index -= (len_before_remove - len_after_remove) + self.as_y_err.append(err_column) + + def remove(self, col_index): + self._remove(col_index, [self.as_x, self.as_y, self.as_y_err]) + + def _remove_associated_yerr_columns(self, col_index): + # we can only have 1 Y Err for Y, so iterating and removing's iterator invalidation is not an + # issue as the code will exit immediately after the removal + for col in self.as_y_err: + if col.error_for_column == col_index: + self.as_y_err.remove(col) + break + + def _make_labels(self, list, label): + return [(col_num, label.format(index),) for index, col_num in enumerate(list)] + + def build_labels(self): + extra_labels = [] + extra_labels.extend(self._make_labels(self.as_x, self.X_LABEL)) + extra_labels.extend(self._make_labels(self.as_y, self.Y_LABEL)) + err_labels = [(err_col.column, self.Y_ERR_LABEL.format(err_col.label_index),) for index, err_col in + enumerate(self.as_y_err)] + extra_labels.extend(err_labels) + return extra_labels + + def find_yerr(self, selected_columns): + """ + Retrieve the corresponding YErr column for each Y column, so that it can be plotted + :param selected_columns: Selected Y columns for which their YErr columns will be retrieved + :return: Dict[Selected Column] = Column with YErr + """ + yerr_for_col = {} + + # for each selected column + for col in selected_columns: + # find the marked error column + for yerr_col in self.as_y_err: + # if found append the YErr's source column - so that the data from the columns + # can be retrieved for plotting the errors + if yerr_col.error_for_column == col: + yerr_for_col[col] = yerr_col.column + + return yerr_for_col diff --git a/qt/python/mantidqt/widgets/tableworkspacedisplay/model.py b/qt/python/mantidqt/widgets/tableworkspacedisplay/model.py new file mode 100644 index 0000000000000000000000000000000000000000..7ac78cac95aab3b05fa999a6d0accfb4a49e090e --- /dev/null +++ b/qt/python/mantidqt/widgets/tableworkspacedisplay/model.py @@ -0,0 +1,89 @@ +# coding=utf-8 +# Mantid Repository : https://github.com/mantidproject/mantid +# +# Copyright © 2018 ISIS Rutherford Appleton Laboratory UKRI, +# NScD Oak Ridge National Laboratory, European Spallation Source +# & Institut Laue - Langevin +# SPDX - License - Identifier: GPL - 3.0 + +# This file is part of the mantid workbench. +# +# +from __future__ import (absolute_import, division, print_function) + +from mantid.dataobjects import PeaksWorkspace, TableWorkspace +from mantid.kernel import V3D +from mantidqt.widgets.tableworkspacedisplay.marked_columns import MarkedColumns + + +class TableWorkspaceDisplayModel: + SPECTRUM_PLOT_LEGEND_STRING = '{}-{}' + BIN_PLOT_LEGEND_STRING = '{}-bin-{}' + + ALLOWED_WORKSPACE_TYPES = [PeaksWorkspace, TableWorkspace] + + @classmethod + def supports(cls, ws): + """ + Checks that the provided workspace is supported by this display. + :param ws: Workspace to be checked for support + :raises ValueError: if the workspace is not supported + """ + if not any(isinstance(ws, allowed_type) for allowed_type in cls.ALLOWED_WORKSPACE_TYPES): + raise ValueError("The workspace type is not supported: {0}".format(ws)) + + def __init__(self, ws): + """ + Initialise the model with the workspace + :param ws: Workspace to be used for providing data + :raises ValueError: if the workspace is not supported + """ + self.supports(ws) + + self.ws = ws + self.ws_num_rows = self.ws.rowCount() + self.ws_num_cols = self.ws.columnCount() + self.ws_column_types = self.ws.columnTypes() + self.marked_columns = MarkedColumns() + self._original_column_headers = self.get_column_headers() + + def _get_v3d_from_str(self, string): + if '[' in string and ']' in string: + string = string[1:-1] + if ',' in string: + return V3D(*[float(x) for x in string.split(',')]) + else: + raise ValueError("'{}' is not a valid V3D string.".format(string)) + + def original_column_headers(self): + return self._original_column_headers[:] + + def build_current_labels(self): + return self.marked_columns.build_labels() + + def get_name(self): + return self.ws.name() + + def get_column_headers(self): + return self.ws.getColumnNames() + + def get_column(self, index): + return self.ws.column(index) + + def get_number_of_rows(self): + return self.ws_num_rows + + def get_number_of_columns(self): + return self.ws_num_cols + + def get_column_header(self, index): + return self.get_column_headers()[index] + + def is_peaks_workspace(self): + return isinstance(self.ws, PeaksWorkspace) + + def set_cell_data(self, row, col, data, is_v3d): + # if the cell contains V3D data, construct a V3D object + # from the string to that it can be properly set + if is_v3d: + data = self._get_v3d_from_str(data) + self.ws.setCell(row, col, data) diff --git a/qt/python/mantidqt/widgets/tableworkspacedisplay/plot_type.py b/qt/python/mantidqt/widgets/tableworkspacedisplay/plot_type.py new file mode 100644 index 0000000000000000000000000000000000000000..b4a127600497467305e4cfaf9f17c360f71878b9 --- /dev/null +++ b/qt/python/mantidqt/widgets/tableworkspacedisplay/plot_type.py @@ -0,0 +1,8 @@ +from mantid.py3compat import Enum + + +class PlotType(Enum): + LINEAR = 1 + SCATTER = 2 + LINE_AND_SYMBOL = 3 + LINEAR_WITH_ERR = 4 diff --git a/qt/python/mantidqt/widgets/tableworkspacedisplay/presenter.py b/qt/python/mantidqt/widgets/tableworkspacedisplay/presenter.py new file mode 100644 index 0000000000000000000000000000000000000000..1c05e2e6091ee22375c5505e9d18791fdb23dc24 --- /dev/null +++ b/qt/python/mantidqt/widgets/tableworkspacedisplay/presenter.py @@ -0,0 +1,327 @@ +# Mantid Repository : https://github.com/mantidproject/mantid +# +# Copyright © 2018 ISIS Rutherford Appleton Laboratory UKRI, +# NScD Oak Ridge National Laboratory, European Spallation Source +# & Institut Laue - Langevin +# SPDX - License - Identifier: GPL - 3.0 + +# This file is part of the mantid workbench. +# +# +from __future__ import absolute_import, division, print_function + +from functools import partial + +from qtpy.QtCore import Qt + +from mantid.simpleapi import DeleteTableRows, StatisticsOfTableWorkspace +from mantidqt.widgets.common.table_copying import copy_cells, show_no_selection_to_copy_toast +from mantidqt.widgets.tableworkspacedisplay.error_column import ErrorColumn +from mantidqt.widgets.tableworkspacedisplay.plot_type import PlotType +from mantidqt.widgets.tableworkspacedisplay.workbench_table_widget_item import WorkbenchTableWidgetItem +from .model import TableWorkspaceDisplayModel +from .view import TableWorkspaceDisplayView + + +class TableWorkspaceDisplay(object): + A_LOT_OF_THINGS_TO_PLOT_MESSAGE = "You selected {} spectra to plot. Are you sure you want to plot that many?" + TOO_MANY_SELECTED_FOR_X = "Too many columns are selected to use as X. Please select only 1." + TOO_MANY_SELECTED_TO_SORT = "Too many columns are selected to sort by. Please select only 1." + TOO_MANY_SELECTED_FOR_PLOT = "Too many columns are selected to plot. Please select only 1." + NUM_SELECTED_FOR_CONFIRMATION = 10 + NO_COLUMN_MARKED_AS_X = "No columns marked as X." + + def __init__(self, ws, plot=None, parent=None, model=None, view=None, name=None): + """ + Creates a display for the provided workspace. + + :param ws: Workspace to be displayed + :param parent: Parent of the widget + :param plot: Plotting function that will be used to plot workspaces. This requires MatPlotLib directly. + Passed in as parameter to allow mocking + :param model: Model to be used by the widget. Passed in as parameter to allow mocking + :param view: View to be used by the widget. Passed in as parameter to allow mocking + :param name: Custom name for the window + """ + # Create model and view, or accept mocked versions + self.model = model if model else TableWorkspaceDisplayModel(ws) + self.name = self.model.get_name() if name is None else name + self.view = view if view else TableWorkspaceDisplayView(self, parent, self.name) + self.parent = parent + self.plot = plot + self.view.set_context_menu_actions(self.view) + + self.update_column_headers() + self.load_data(self.view) + + # connect to cellChanged signal after the data has been loaded + # all consecutive triggers will be from user actions + self.view.itemChanged.connect(self.handleItemChanged) + + @classmethod + def supports(cls, ws): + """ + Checks that the provided workspace is supported by this display. + :param ws: Workspace to be checked for support + :raises ValueError: if the workspace is not supported + """ + return TableWorkspaceDisplayModel.supports(ws) + + def handleItemChanged(self, item): + """ + :type item: WorkbenchTableWidgetItem + :param item: + :return: + """ + try: + self.model.set_cell_data(item.row(), item.column(), item.data(Qt.DisplayRole), item.is_v3d) + item.update() + except ValueError: + self.view.show_warning("Error: Trying to set invalid data for the column.") + except Exception as x: + self.view.show_warning("Unknown error occurred: {}".format(x)) + finally: + item.reset() + + def update_column_headers(self): + """ + :param extra_labels: Extra labels to be appended to the column headers. + Expected format: [(id, label), (2, "X"),...] + :type extra_labels: List[Tuple[int, str]] + :return: + """ + # deep copy the original headers so that they are not changed by the appending of the label + column_headers = self.model.original_column_headers() + num_headers = len(column_headers) + self.view.setColumnCount(num_headers) + + extra_labels = self.model.build_current_labels() + if len(extra_labels) > 0: + for index, label in extra_labels: + column_headers[index] += str(label) + + self.view.setHorizontalHeaderLabels(column_headers) + + def load_data(self, table): + num_rows = self.model.get_number_of_rows() + table.setRowCount(num_rows) + + num_cols = self.model.get_number_of_columns() + + # the table should be editable if the ws is not PeaksWS + editable = not self.model.is_peaks_workspace() + + for col in range(num_cols): + column_data = self.model.get_column(col) + for row in range(num_rows): + item = WorkbenchTableWidgetItem(column_data[row], editable=editable) + table.setItem(row, col, item) + + def action_copy_cells(self): + copy_cells(self.view) + + def action_copy_bin_values(self): + copy_cells(self.view) + + def action_copy_spectrum_values(self): + copy_cells(self.view) + + def action_keypress_copy(self): + copy_cells(self.view) + + def action_delete_row(self): + selection_model = self.view.selectionModel() + if not selection_model.hasSelection(): + show_no_selection_to_copy_toast() + return + + selected_rows = selection_model.selectedRows() + selected_rows_list = [index.row() for index in selected_rows] + selected_rows_str = ",".join([str(row) for row in selected_rows_list]) + + DeleteTableRows(self.model.ws, selected_rows_str) + # Reverse the list so that we delete in order from bottom -> top + # this prevents the row index from shifting up when deleting rows above + for row in reversed(selected_rows_list): + self.view.removeRow(row) + + def _get_selected_columns(self, max_selected=None, message_if_over_max=None): + selection_model = self.view.selectionModel() + if not selection_model.hasSelection(): + show_no_selection_to_copy_toast() + raise ValueError("No selection") + + selected_columns = selection_model.selectedColumns() + num_selected_columns = len(selected_columns) + + if max_selected and message_if_over_max and num_selected_columns > max_selected: + # if over the maximum allowed selection + self.view.show_warning(message_if_over_max) + raise ValueError("Too many selected") + elif num_selected_columns == 0: + # if no columns are selected + show_no_selection_to_copy_toast() + raise ValueError("No selection") + else: + return [index.column() for index in selected_columns] + + def action_statistics_on_columns(self): + try: + selected_columns = self._get_selected_columns() + except ValueError: + return + + stats = StatisticsOfTableWorkspace(self.model.ws, selected_columns) + TableWorkspaceDisplay(stats, parent=self.parent, name="Column Statistics of {}".format(self.name)) + + def action_hide_selected(self): + try: + selected_columns = self._get_selected_columns() + except ValueError: + return + for column_index in selected_columns: + self.view.hideColumn(column_index) + + def action_show_all_columns(self): + for column_index in range(self.view.columnCount()): + self.view.showColumn(column_index) + + def _action_set_as(self, add_to_list_func): + try: + selected_columns = self._get_selected_columns() + except ValueError: + return + + for col in selected_columns: + add_to_list_func(col) + + self.update_column_headers() + + def action_set_as_x(self): + self._action_set_as(self.model.marked_columns.add_x) + + def action_set_as_y(self): + self._action_set_as(self.model.marked_columns.add_y) + + def action_set_as_y_err(self, error_for_column, label_index): + """ + + :param error_for_column: The real index of the column for which the error is being marked + :param label_index: The index present in the label of the column for which the error is being marked + This will be the number in <ColumnName>[Y10] -> the 10 + """ + try: + selected_columns = self._get_selected_columns(1, "Too many selected to set as Y Error") + except ValueError: + return + + selected_column = selected_columns[0] + try: + err_column = ErrorColumn(selected_column, error_for_column, label_index) + except ValueError as e: + self.view.show_warning(e.message) + return + + self.model.marked_columns.add_y_err(err_column) + self.update_column_headers() + + def action_set_as_none(self): + self._action_set_as(self.model.marked_columns.remove) + + def action_sort_ascending(self, order): + try: + selected_columns = self._get_selected_columns(1, self.TOO_MANY_SELECTED_TO_SORT) + except ValueError: + return + + selected_column = selected_columns[0] + self.view.sortByColumn(selected_column, order) + + def action_plot(self, plot_type): + try: + selected_columns = self._get_selected_columns() + except ValueError: + return + + x_cols = list(set(selected_columns).intersection(self.model.marked_columns.as_x)) + num_x_cols = len(x_cols) + # if there is more than 1 column marked as X in the selection + # -> show toast to the user and do nothing + if num_x_cols > 1: + self.view.show_warning(self.TOO_MANY_SELECTED_FOR_X) + return + elif num_x_cols == 1: + # Only 1 X column present in the current selection model + # -> Use it as X for the plot + selected_x = x_cols[0] + else: + # No X column present in the current selection model + # -> Use the first column marked as X (if present) + if len(self.model.marked_columns.as_x) == 0: + # If no columns are marked as X show user message and exit + self.view.show_warning(self.NO_COLUMN_MARKED_AS_X) + return + selected_x = self.model.marked_columns.as_x[0] + + try: + # Remove the X column from the selected columns, this is + # in case a column is being used as both X and Y + selected_columns.remove(selected_x) + except ValueError: + pass + + if len(selected_columns) == 0: + self.view.show_warning("Cannot plot column against itself.") + return + + self._do_plot(selected_columns, selected_x, plot_type) + + def _do_plot(self, selected_columns, selected_x, plot_type): + if plot_type == PlotType.LINEAR_WITH_ERR: + yerr = self.model.marked_columns.find_yerr(selected_columns) + if len(yerr) != len(selected_columns): + self.view.show_warning("There is no associated YErr for each selected Y column.") + return + x = self.model.get_column(selected_x) + + fig, ax = self.plot.subplots(subplot_kw={'projection': 'mantid'}) + fig.canvas.set_window_title(self.model.get_name()) + ax.set_xlabel(self.model.get_column_header(selected_x)) + + plot_func = self._get_plot_function_from_type(ax, plot_type) + kwargs = {} + for column in selected_columns: + if plot_type == PlotType.LINEAR_WITH_ERR: + yerr_column = yerr[column] + yerr_column_data = self.model.get_column(yerr_column) + kwargs["yerr"] = yerr_column_data + + y = self.model.get_column(column) + column_label = self.model.get_column_header(column) + try: + plot_func(x, y, label='Column {}'.format(column_label), **kwargs) + except ValueError as e: + # TODO log error? + self.view.show_warning( + "One or more of the columns being plotted contain invalid data for MatPlotLib." + "\n\nError message:\n{}".format(e), "Invalid data - Mantid Workbench") + return + + ax.set_ylabel(column_label) + ax.legend() + fig.show() + + def _get_plot_function_from_type(self, ax, type): + if type == PlotType.LINEAR: + plot_func = ax.plot + elif type == PlotType.SCATTER: + plot_func = ax.scatter + elif type == PlotType.LINE_AND_SYMBOL: + plot_func = partial(ax.plot, marker='o') + elif type == PlotType.LINEAR_WITH_ERR: + plot_func = ax.errorbar + else: + raise ValueError("Plot Type: {} not currently supported!".format(type)) + return plot_func + + def get_columns_marked_as_y(self): + return self.model.marked_columns.as_y[:] diff --git a/qt/python/mantidqt/widgets/tableworkspacedisplay/test/__init__.py b/qt/python/mantidqt/widgets/tableworkspacedisplay/test/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/qt/python/mantidqt/widgets/tableworkspacedisplay/test/test_tableworkspacedisplay_error_column.py b/qt/python/mantidqt/widgets/tableworkspacedisplay/test/test_tableworkspacedisplay_error_column.py new file mode 100644 index 0000000000000000000000000000000000000000..4aae73eb81fc9d70ac97e726269ab448600c8a32 --- /dev/null +++ b/qt/python/mantidqt/widgets/tableworkspacedisplay/test/test_tableworkspacedisplay_error_column.py @@ -0,0 +1,33 @@ +import unittest + +from mantidqt.widgets.tableworkspacedisplay.error_column import ErrorColumn + + +class ErrorColumnTest(unittest.TestCase): + + def test_correct_init(self): + ErrorColumn(0, 1, 0) + + def test_raises_for_same_y_and_yerr(self): + self.assertRaises(ValueError, lambda: ErrorColumn(2, 2, 3)) + + def test_eq_versus_ErrorColumn(self): + ec1 = ErrorColumn(0, 1, 0) + ec2 = ErrorColumn(0, 1, 0) + self.assertEqual(ec1, ec2) + + ec1 = ErrorColumn(0, 3, 0) + ec2 = ErrorColumn(0, 1, 0) + self.assertEqual(ec1, ec2) + + ec1 = ErrorColumn(2, 3, 0) + ec2 = ErrorColumn(0, 3, 0) + self.assertEqual(ec1, ec2) + + def test_eq_versus_same_int(self): + ec = ErrorColumn(150, 1, 0) + self.assertEqual(ec, 150) + + def test_eq_unsupported_type(self): + ec = ErrorColumn(150, 1, 0) + self.assertRaises(RuntimeError, lambda: ec == "awd") diff --git a/qt/python/mantidqt/widgets/tableworkspacedisplay/test/test_tableworkspacedisplay_marked_columns.py b/qt/python/mantidqt/widgets/tableworkspacedisplay/test/test_tableworkspacedisplay_marked_columns.py new file mode 100644 index 0000000000000000000000000000000000000000..2d91244c05efa799f6b375048228fca58e48bac3 --- /dev/null +++ b/qt/python/mantidqt/widgets/tableworkspacedisplay/test/test_tableworkspacedisplay_marked_columns.py @@ -0,0 +1,343 @@ +# Mantid Repository : https://github.com/mantidproject/mantid +# +# Copyright © 2018 ISIS Rutherford Appleton Laboratory UKRI, +# NScD Oak Ridge National Laboratory, European Spallation Source +# & Institut Laue - Langevin +# SPDX - License - Identifier: GPL - 3.0 + +# This file is part of the mantid workbench. +# +# +from __future__ import (absolute_import, division, print_function) + +import unittest +from itertools import permutations + +from mantidqt.widgets.tableworkspacedisplay.error_column import ErrorColumn +from mantidqt.widgets.tableworkspacedisplay.marked_columns import MarkedColumns + + +class ReferenceHolder: + def __init__(self, test_func, test_list): + self.func = test_func + self.list = test_list + + +class MarkedColumnsTest(unittest.TestCase): + + def test_add_x(self): + mc = MarkedColumns() + self.execute_add(mc.add_x, mc.as_x) + + def test_add_y(self): + mc = MarkedColumns() + self.execute_add(mc.add_y, mc.as_y) + + def execute_add(self, func_to_add, list_to_check): + func_to_add(2) + self.assertEqual(1, len(list_to_check)) + func_to_add(3) + self.assertEqual(2, len(list_to_check)) + func_to_add(4000000) + self.assertEqual(3, len(list_to_check)) + + def test_add_y_err(self): + """ + Test adding YErr columns that do not overlap in any way + """ + mc = MarkedColumns() + ec = ErrorColumn(2, 4, 0) + mc.add_y_err(ec) + self.assertEqual(1, len(mc.as_y_err)) + ec = ErrorColumn(3, 5, 0) + mc.add_y_err(ec) + self.assertEqual(2, len(mc.as_y_err)) + ec = ErrorColumn(1, 6, 0) + mc.add_y_err(ec) + self.assertEqual(3, len(mc.as_y_err)) + + def test_add_x_duplicate_column(self): + mc = MarkedColumns() + self.execute_add_duplicate_column(mc.add_x, mc.as_x) + + def test_add_y_duplicate_column(self): + mc = MarkedColumns() + self.execute_add_duplicate_column(mc.add_y, mc.as_y) + + def execute_add_duplicate_column(self, func_to_add, list_to_check): + func_to_add(2) + self.assertEqual(1, len(list_to_check)) + func_to_add(2) + self.assertEqual(1, len(list_to_check)) + func_to_add(55) + self.assertEqual(2, len(list_to_check)) + func_to_add(55) + self.assertEqual(2, len(list_to_check)) + + def test_add_y_err_duplicate_column(self): + mc = MarkedColumns() + ec = ErrorColumn(2, 4, 0) + + mc.add_y_err(ec) + self.assertEqual(1, len(mc.as_y_err)) + mc.add_y_err(ec) + self.assertEqual(1, len(mc.as_y_err)) + + ec2 = ErrorColumn(3, 5, 0) + mc.add_y_err(ec2) + self.assertEqual(2, len(mc.as_y_err)) + mc.add_y_err(ec2) + self.assertEqual(2, len(mc.as_y_err)) + + def test_add_already_marked(self): + mc = MarkedColumns() + + relevant_funcs = [ReferenceHolder(mc.add_x, mc.as_x), + ReferenceHolder(mc.add_y, mc.as_y)] + all_combinations = permutations(relevant_funcs, 2) + + for combination in all_combinations: + self.execute_add_already_marked(*combination) + + def execute_add_already_marked(self, first, two): + """ + If trying to mark a column that is already marked -> all other markings must be removed + :type first: ReferenceHolder + :type two: ReferenceHolder + :return: + """ + + # add column in first + first.func(33) + self.assertEqual(1, len(first.list)) + + # add the same column in the second + two.func(33) + + # it should have been removed from the first and only present in the second + self.assertEqual(0, len(first.list)) + self.assertEqual(1, len(two.list)) + + def test_add_y_err_duplicate_column_same_source_column(self): + """ + Test for adding a new YErr column with the same source column + -> The new YErr must replace the old one + """ + mc = MarkedColumns() + ec = ErrorColumn(column=2, error_for_column=4, label_index=0) + mc.add_y_err(ec) + self.assertEqual(1, len(mc.as_y_err)) + self.assertEqual(2, mc.as_y_err[0].column) + self.assertEqual(4, mc.as_y_err[0].error_for_column) + + # different source column but contains error for the same column + # adding this one should replace the first one + ec2 = ErrorColumn(column=2, error_for_column=5, label_index=0) + mc.add_y_err(ec2) + self.assertEqual(1, len(mc.as_y_err)) + self.assertEqual(2, mc.as_y_err[0].column) + self.assertEqual(5, mc.as_y_err[0].error_for_column) + + def test_add_y_err_duplicate_column_different_reference_col(self): + """ + Test for adding a new YErr column with a _different_ source column but same reference column + -> The new YErr must replace the old one + """ + mc = MarkedColumns() + ec = ErrorColumn(column=2, error_for_column=4, label_index=0) + mc.add_y_err(ec) + self.assertEqual(1, len(mc.as_y_err)) + self.assertEqual(2, mc.as_y_err[0].column) + self.assertEqual(4, mc.as_y_err[0].error_for_column) + + # different source column but contains error for the same column + # adding this one should replace the first one + ec2 = ErrorColumn(column=3, error_for_column=4, label_index=0) + mc.add_y_err(ec2) + self.assertEqual(1, len(mc.as_y_err)) + self.assertEqual(3, mc.as_y_err[0].column) + self.assertEqual(4, mc.as_y_err[0].error_for_column) + + def test_changing_y_to_x_removes_associated_yerr_columns(self): + """ + Test to check if a first column is marked as Y, a second column YErr is associated with it, but then + the first one is changed to X - the YErr mark should be removed + """ + mc = MarkedColumns() + mc.add_y(4) + ec = ErrorColumn(column=2, error_for_column=4, label_index=0) + mc.add_y_err(ec) + + # check that we have both a Y col and an associated YErr + self.assertEqual(1, len(mc.as_y)) + self.assertEqual(1, len(mc.as_y_err)) + + mc.add_x(4) + # changing the column to X should have removed it from Y and Yerr + self.assertEqual(1, len(mc.as_x)) + self.assertEqual(0, len(mc.as_y)) + self.assertEqual(0, len(mc.as_y_err)) + + def test_changing_y_to_none_removes_associated_yerr_columns(self): + """ + Test to check if a first column is marked as Y, a second column YErr is associated with it, but then + the first one is changed to X - the YErr mark should be removed + """ + mc = MarkedColumns() + mc.add_y(4) + ec = ErrorColumn(column=2, error_for_column=4, label_index=0) + mc.add_y_err(ec) + + # check that we have both a Y col and an associated YErr + self.assertEqual(1, len(mc.as_y)) + self.assertEqual(1, len(mc.as_y_err)) + + mc.remove(4) + # changing the column to NONE should have removed it from X, Y and YErr + self.assertEqual(0, len(mc.as_x)) + self.assertEqual(0, len(mc.as_y)) + self.assertEqual(0, len(mc.as_y_err)) + + def test_remove_column(self): + mc = MarkedColumns() + mc.add_y(4) + mc.add_x(3) + ec = ErrorColumn(column=2, error_for_column=6, label_index=0) + mc.add_y_err(ec) + + self.assertEqual(1, len(mc.as_x)) + self.assertEqual(1, len(mc.as_y)) + self.assertEqual(1, len(mc.as_y_err)) + + mc.remove(4) + self.assertEqual(0, len(mc.as_y)) + self.assertEqual(1, len(mc.as_y_err)) + self.assertEqual(1, len(mc.as_x)) + + mc.remove(3) + self.assertEqual(0, len(mc.as_x)) + self.assertEqual(0, len(mc.as_y)) + self.assertEqual(1, len(mc.as_y_err)) + + mc.remove(2) + self.assertEqual(0, len(mc.as_x)) + self.assertEqual(0, len(mc.as_y)) + self.assertEqual(0, len(mc.as_y_err)) + + def test_build_labels_x_y(self): + # TODO test this edge case: mark all columns Y, remove one that is not the last one! + mc = MarkedColumns() + mc.add_y(0) + mc.add_y(1) + mc.add_y(2) + mc.add_y(3) + + # note that the max Y label number will decrease as more Y columns are being changed to X + expected = [(0, '[Y0]'), (1, '[Y1]'), (2, '[Y2]'), (3, '[Y3]')] + self.assertEqual(expected, mc.build_labels()) + + expected = [(1, '[X0]'), (0, '[Y0]'), (2, '[Y1]'), (3, '[Y2]')] + mc.add_x(1) + self.assertEqual(expected, mc.build_labels()) + expected = [(1, '[X0]'), (3, '[X1]'), (0, '[Y0]'), (2, '[Y1]')] + mc.add_x(3) + self.assertEqual(expected, mc.build_labels()) + + def test_build_labels_y_and_yerr_change_middle(self): + mc = MarkedColumns() + mc.add_y(0) + mc.add_y(1) + mc.add_y(2) + + # change one of the columns to YErr + mc.add_y_err(ErrorColumn(1, 0, 0)) + expected = [(0, '[Y0]'), (2, '[Y1]'), (1, '[Y0_YErr]')] + self.assertEqual(expected, mc.build_labels()) + + # change the last Y column to YErr + mc.add_y_err(ErrorColumn(2, 0, 0)) + expected = [(0, '[Y0]'), (2, '[Y0_YErr]')] + self.assertEqual(expected, mc.build_labels()) + + def test_build_labels_y_and_yerr_change_first(self): + mc = MarkedColumns() + mc.add_y(0) + mc.add_y(1) + mc.add_y(2) + + # change one of the columns to YErr + mc.add_y_err(ErrorColumn(0, 1, 1)) + # note: the first column is being set -> this decreases the label index of all columns to its right by 1 + expected = [(1, '[Y0]'), (2, '[Y1]'), (0, '[Y0_YErr]')] + self.assertEqual(expected, mc.build_labels()) + + # change the last Y column to YErr + mc.add_y_err(ErrorColumn(2, 1, 0)) + expected = [(1, '[Y0]'), (2, '[Y0_YErr]')] + self.assertEqual(expected, mc.build_labels()) + + def test_build_labels_x_y_and_yerr(self): + mc = MarkedColumns() + mc.add_y(0) + mc.add_y(1) + mc.add_y(2) + mc.add_y(3) + + mc.add_y_err(ErrorColumn(1, 0, 0)) + expected = [(0, '[Y0]'), (2, '[Y1]'), (3, '[Y2]'), (1, '[Y0_YErr]')] + self.assertEqual(expected, mc.build_labels()) + + expected = [(1, '[X0]'), (0, '[Y0]'), (2, '[Y1]'), (3, '[Y2]')] + mc.add_x(1) + self.assertEqual(expected, mc.build_labels()) + + expected = [(1, '[X0]'), (2, '[Y0]'), (3, '[Y1]'), (0, '[Y1_YErr]')] + mc.add_y_err(ErrorColumn(0, 3, 2)) + self.assertEqual(expected, mc.build_labels()) + + def test_fail_to_add_yerr_for_x(self): + mc = MarkedColumns() + mc.add_y(0) + mc.add_y(1) + mc.add_y(2) + mc.add_y(3) + + mc.add_y_err(ErrorColumn(1, 0, 0)) + expected = [(0, '[Y0]'), (2, '[Y1]'), (3, '[Y2]'), (1, '[Y0_YErr]')] + self.assertEqual(expected, mc.build_labels()) + + expected = [(1, '[X0]'), (0, '[Y0]'), (2, '[Y1]'), (3, '[Y2]')] + mc.add_x(1) + self.assertEqual(expected, mc.build_labels()) + + self.assertRaises(ValueError, lambda: mc.add_y_err(ErrorColumn(0, 1, 2))) + + def test_fail_to_add_yerr_for_another_yerr(self): + mc = MarkedColumns() + mc.add_y(0) + mc.add_y(1) + mc.add_y(2) + mc.add_y(3) + + mc.add_y_err(ErrorColumn(1, 0, 0)) + expected = [(0, '[Y0]'), (2, '[Y1]'), (3, '[Y2]'), (1, '[Y0_YErr]')] + self.assertEqual(expected, mc.build_labels()) + + self.assertRaises(ValueError, lambda: mc.add_y_err(ErrorColumn(0, 1, 2))) + + def test_find_yerr(self): + mc = MarkedColumns() + mc.add_y(0) + mc.add_y(1) + mc.add_y(2) + mc.add_y(3) + + mc.add_y_err(ErrorColumn(4, 1, 1)) + expected = {1: 4} + self.assertEqual(expected, mc.find_yerr([1])) + # Replace the Y column, which has an associated YErr. This should remove the YErr as well + mc.add_y_err(ErrorColumn(1, 3, 1)) + expected = {3: 1} + self.assertEqual(expected, mc.find_yerr([0, 1, 2, 3])) + mc.add_y_err(ErrorColumn(4, 2, 1)) + expected = {2: 4, 3: 1} + self.assertEqual(expected, mc.find_yerr([0, 1, 2, 3])) diff --git a/qt/python/mantidqt/widgets/tableworkspacedisplay/test/test_tableworkspacedisplay_model.py b/qt/python/mantidqt/widgets/tableworkspacedisplay/test/test_tableworkspacedisplay_model.py new file mode 100644 index 0000000000000000000000000000000000000000..f6cb3a2d6d089cb47e3ba366973b9476cb0d8663 --- /dev/null +++ b/qt/python/mantidqt/widgets/tableworkspacedisplay/test/test_tableworkspacedisplay_model.py @@ -0,0 +1,107 @@ +# Mantid Repository : https://github.com/mantidproject/mantid +# +# Copyright © 2018 ISIS Rutherford Appleton Laboratory UKRI, +# NScD Oak Ridge National Laboratory, European Spallation Source +# & Institut Laue - Langevin +# SPDX - License - Identifier: GPL - 3.0 + +# This file is part of the mantid workbench. +# +# +from __future__ import (absolute_import, division, print_function) + +import functools +import unittest + +from mock import Mock + +from mantid.kernel import V3D +from mantidqt.widgets.matrixworkspacedisplay.test_helpers.matrixworkspacedisplay_common import \ + MockWorkspace +from mantidqt.widgets.tableworkspacedisplay.model import TableWorkspaceDisplayModel + + +def with_mock_model(func): + # type: (callable) -> callable + @functools.wraps(func) + def wrapper(self): + ws = MockWorkspace() + model = TableWorkspaceDisplayModel(ws) + return func(self, model) + + return wrapper + + +class TableWorkspaceDisplayModelTest(unittest.TestCase): + @classmethod + def setUpClass(cls): + # Allow the MockWorkspace to work within the model + TableWorkspaceDisplayModel.ALLOWED_WORKSPACE_TYPES.append(MockWorkspace) + + def test_get_name(self): + ws = MockWorkspace() + expected_name = "TEST_WORKSPACE" + ws.name = Mock(return_value=expected_name) + model = TableWorkspaceDisplayModel(ws) + + self.assertEqual(expected_name, model.get_name()) + + def test_raises_with_unsupported_workspace(self): + self.assertRaises(ValueError, lambda: TableWorkspaceDisplayModel([])) + self.assertRaises(ValueError, lambda: TableWorkspaceDisplayModel(1)) + self.assertRaises(ValueError, lambda: TableWorkspaceDisplayModel("test_string")) + + @with_mock_model + def test_get_v3d_from_str(self, model): + """ + :type model: TableWorkspaceDisplayModel + """ + self.assertEqual(V3D(1, 2, 3), model._get_v3d_from_str("1,2,3")) + self.assertEqual(V3D(4, 5, 6), model._get_v3d_from_str("[4,5,6]")) + + @with_mock_model + def test_set_cell_data_non_v3d(self, model): + """ + :type model: TableWorkspaceDisplayModel + """ + test_data = 4444 + + expected_col = 1111 + expected_row = 1 + + model.set_cell_data(expected_row, expected_col, test_data, False) + + # check that the correct conversion function was retrieved + # -> the one for the column for which the data is being set + model.ws.setCell.assert_called_once_with(expected_row, expected_col, test_data) + + @with_mock_model + def test_set_cell_data_v3d(self, model): + """ + :type model: TableWorkspaceDisplayModel + """ + test_data = "[1,2,3]" + + expected_col = 1111 + expected_row = 1 + + model.set_cell_data(expected_row, expected_col, test_data, True) + + # check that the correct conversion function was retrieved + # -> the one for the column for which the data is being set + model.ws.setCell.assert_called_once_with(expected_row, expected_col, V3D(1, 2, 3)) + + def test_no_raise_with_supported_workspace(self): + from mantid.simpleapi import CreateEmptyTableWorkspace + ws = MockWorkspace() + expected_name = "TEST_WORKSPACE" + ws.name = Mock(return_value=expected_name) + + # no need to assert anything - if the constructor raises the test will fail + TableWorkspaceDisplayModel(ws) + + ws = CreateEmptyTableWorkspace() + TableWorkspaceDisplayModel(ws) + + +if __name__ == '__main__': + unittest.main() diff --git a/qt/python/mantidqt/widgets/tableworkspacedisplay/test/test_tableworkspacedisplay_presenter.py b/qt/python/mantidqt/widgets/tableworkspacedisplay/test/test_tableworkspacedisplay_presenter.py new file mode 100644 index 0000000000000000000000000000000000000000..9fdd788ab0bceddab2ade8e0e3f7d73956223011 --- /dev/null +++ b/qt/python/mantidqt/widgets/tableworkspacedisplay/test/test_tableworkspacedisplay_presenter.py @@ -0,0 +1,27 @@ +# Mantid Repository : https://github.com/mantidproject/mantid +# +# Copyright © 2018 ISIS Rutherford Appleton Laboratory UKRI, +# NScD Oak Ridge National Laboratory, European Spallation Source +# & Institut Laue - Langevin +# SPDX - License - Identifier: GPL - 3.0 + +# This file is part of the mantid workbench. +# +# +# from __future__ import (absolute_import, division, print_function) + +# import unittest + +# from mock import Mock + +# from mantidqt.widgets.matrixworkspacedisplay.test_helpers.matrixworkspacedisplay_common import MockQModelIndex, \ +# MockWorkspace +# from mantidqt.widgets.matrixworkspacedisplay.test_helpers.mock_matrixworkspacedisplay import MockQTableView +# from mantidqt.widgets.tableworkspacedisplay.presenter import TableWorkspaceDisplay +# from mantidqt.widgets.tableworkspacedisplay.test_helpers import MockTableWorkspaceDisplayView + + +# class TableWorkspaceDisplayPresenterTest(unittest.TestCase): +# def assertNotCalled(self, mock): +# self.assertEqual(0, mock.call_count) +# if __name__ == '__main__': +# unittest.main() diff --git a/qt/python/mantidqt/widgets/tableworkspacedisplay/test/test_tableworkspacedisplay_workbench_table_widget_item.py b/qt/python/mantidqt/widgets/tableworkspacedisplay/test/test_tableworkspacedisplay_workbench_table_widget_item.py new file mode 100644 index 0000000000000000000000000000000000000000..a72a72a11290ca2891b9598279107321f7d73e04 --- /dev/null +++ b/qt/python/mantidqt/widgets/tableworkspacedisplay/test/test_tableworkspacedisplay_workbench_table_widget_item.py @@ -0,0 +1,111 @@ +# Mantid Repository : https://github.com/mantidproject/mantid +# +# Copyright © 2018 ISIS Rutherford Appleton Laboratory UKRI, +# NScD Oak Ridge National Laboratory, European Spallation Source +# & Institut Laue - Langevin +# SPDX - License - Identifier: GPL - 3.0 + +# This file is part of the mantid workbench. +# +# +from __future__ import (absolute_import, division, print_function) + +import unittest + +from qtpy.QtCore import Qt + +from mantid.kernel import V3D +from mantidqt.widgets.tableworkspacedisplay.workbench_table_widget_item import WorkbenchTableWidgetItem + + +class WorkbenchTableWidgetItemTest(unittest.TestCase): + def test_initialise_editable_int(self): + """ + Test that the widget is correctly initialised and the type is correctly kept in the .data call + """ + mock_data = 12 + w = WorkbenchTableWidgetItem(mock_data, editable=True) + self.assertEqual(mock_data, w.display_data) + self.assertEqual(mock_data, w.data(Qt.DisplayRole)) + + def test_initialise_editable_bool(self): + """ + Test that the widget is correctly initialised and the type is correctly kept in the .data call + """ + mock_data = True + w = WorkbenchTableWidgetItem(mock_data, editable=True) + self.assertEqual(mock_data, w.display_data) + self.assertEqual(mock_data, w.data(Qt.DisplayRole)) + + def test_initialise_readonly(self): + """ + Test that the widget converts everything to string if read only + :return: + """ + mock_data = 123 + w = WorkbenchTableWidgetItem(mock_data, editable=False) + self.assertEqual(str(mock_data), w.data(Qt.DisplayRole)) + + mock_data = 1.3333333 + w = WorkbenchTableWidgetItem(mock_data, editable=False) + self.assertEqual(str(mock_data), w.data(Qt.DisplayRole)) + + mock_data = V3D(1, 2, 3) + w = WorkbenchTableWidgetItem(mock_data, editable=False) + self.assertEqual(str(mock_data), w.data(Qt.DisplayRole)) + + mock_data = True + w = WorkbenchTableWidgetItem(mock_data, editable=False) + self.assertEqual(str(mock_data), w.data(Qt.DisplayRole)) + + mock_data = "apples" + w = WorkbenchTableWidgetItem(mock_data, editable=False) + self.assertEqual(str(mock_data), w.data(Qt.DisplayRole)) + + def test_initialise_editable_with_v3d(self): + mock_data = V3D(1, 2, 3) + w = WorkbenchTableWidgetItem(mock_data, True) + self.assertEqual(str(mock_data), w.data(Qt.DisplayRole)) + # the original data of the V3D is stored as a string too + self.assertEqual(str(mock_data), w.display_data) + + def test_initialise_editable_with_float(self): + mock_data = 42.00 + w = WorkbenchTableWidgetItem(mock_data, True) + self.assertEqual(mock_data, w.data(Qt.DisplayRole)) + self.assertEqual(mock_data, w.display_data) + + def test_lt(self): + """ + Test that the widget properly compares with other widgets. + + :return: + """ + w1 = WorkbenchTableWidgetItem(500, editable=False) + w2 = WorkbenchTableWidgetItem(1500, editable=False) + self.assertTrue(w1 < w2) + + w1 = WorkbenchTableWidgetItem(100.40, editable=False) + w2 = WorkbenchTableWidgetItem(100.41, editable=False) + self.assertTrue(w1 < w2) + + w1 = WorkbenchTableWidgetItem("apples", editable=False) + w2 = WorkbenchTableWidgetItem("potatoes", editable=False) + self.assertTrue(w1 < w2) + + def test_reset(self): + w = WorkbenchTableWidgetItem(500, editable=False) + + w.display_data = 4444 + w.reset() + + self.assertEqual(4444, w.data(Qt.DisplayRole)) + + def test_update(self): + w = WorkbenchTableWidgetItem(500, editable=False) + w.setData(Qt.DisplayRole, 4444) + w.update() + self.assertEqual(4444, w.display_data) + + +if __name__ == '__main__': + unittest.main() diff --git a/qt/python/mantidqt/widgets/tableworkspacedisplay/test_helpers/__init__.py b/qt/python/mantidqt/widgets/tableworkspacedisplay/test_helpers/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..0bd8c24be2b8bafb760d7df108f16bcb30c5ad01 --- /dev/null +++ b/qt/python/mantidqt/widgets/tableworkspacedisplay/test_helpers/__init__.py @@ -0,0 +1,21 @@ +from mock import Mock + +from mantidqt.widgets.matrixworkspacedisplay.test_helpers.mock_matrixworkspacedisplay import MockQTableView + + +class MockTableWorkspaceDisplayView: + def __init__(self): + self.set_context_menu_actions = Mock() + self.table_x = MockQTableView() + self.table_y = MockQTableView() + self.table_e = MockQTableView() + self.set_model = Mock() + self.copy_to_clipboard = Mock() + self.show_mouse_toast = Mock() + self.ask_confirmation = None + + +class MockTableWorkspaceDisplayModel: + def __init__(self): + self.get_spectrum_plot_label = Mock() + self.get_bin_plot_label = Mock() diff --git a/qt/python/mantidqt/widgets/tableworkspacedisplay/view.py b/qt/python/mantidqt/widgets/tableworkspacedisplay/view.py new file mode 100644 index 0000000000000000000000000000000000000000..264535848db926b78f565e7ec1c5af6712156d41 --- /dev/null +++ b/qt/python/mantidqt/widgets/tableworkspacedisplay/view.py @@ -0,0 +1,212 @@ +# Mantid Repository : https://github.com/mantidproject/mantid +# +# Copyright © 2018 ISIS Rutherford Appleton Laboratory UKRI, +# NScD Oak Ridge National Laboratory, European Spallation Source +# & Institut Laue - Langevin +# SPDX - License - Identifier: GPL - 3.0 + +# This file is part of the mantid workbench. +# +# +from __future__ import (absolute_import, division, print_function) + +import sys +from functools import partial + +from qtpy import QtGui +from qtpy.QtCore import QVariant, Qt +from qtpy.QtGui import QKeySequence +from qtpy.QtWidgets import (QAction, QHeaderView, QItemEditorFactory, QMenu, QMessageBox, + QStyledItemDelegate, QTableWidget) + +import mantidqt.icons +from mantidqt.widgets.tableworkspacedisplay.plot_type import PlotType + + +class PreciseDoubleFactory(QItemEditorFactory): + def __init__(self): + QItemEditorFactory.__init__(self) + + def createEditor(self, user_type, parent): + widget = super(PreciseDoubleFactory, self).createEditor(user_type, parent) + if user_type == QVariant.Double: + widget.setFrame(True) + widget.setDecimals(16) + widget.setRange(sys.float_info.min, sys.float_info.max) + + return widget + + +class TableWorkspaceDisplayView(QTableWidget): + def __init__(self, presenter, parent=None, name=''): + super(TableWorkspaceDisplayView, self).__init__(parent) + + self.presenter = presenter + self.COPY_ICON = mantidqt.icons.get_icon("fa.files-o") + self.DELETE_ROW = mantidqt.icons.get_icon("fa.minus-square-o") + self.STATISTICS_ON_ROW = mantidqt.icons.get_icon('fa.fighter-jet') + self.GRAPH_ICON = mantidqt.icons.get_icon('fa.line-chart') + self.TBD = mantidqt.icons.get_icon('fa.question') + + item_delegate = QStyledItemDelegate(self) + item_delegate.setItemEditorFactory(PreciseDoubleFactory()) + self.setItemDelegate(item_delegate) + + self.setWindowTitle("{} - Mantid".format(name)) + self.setWindowFlags(Qt.Window) + + self.resize(600, 400) + self.show() + + def doubleClickedHeader(self): + print("Double clicked WOO") + + def keyPressEvent(self, event): + if event.matches(QKeySequence.Copy): + self.presenter.action_keypress_copy() + return + elif event.key() == Qt.Key_F2 or event.key() == Qt.Key_Return or event.key() == Qt.Key_Enter: + self.edit(self.currentIndex()) + return + + def set_context_menu_actions(self, table): + """ + Sets up the context menu actions for the table + :type table: QTableView + :param table: The table whose context menu actions will be set up. + :param ws_read_function: The read function used to efficiently retrieve data directly from the workspace + """ + copy_action = QAction(self.COPY_ICON, "Copy", table) + copy_action.triggered.connect(self.presenter.action_copy_cells) + + table.setContextMenuPolicy(Qt.ActionsContextMenu) + table.addAction(copy_action) + + horizontalHeader = table.horizontalHeader() + horizontalHeader.setContextMenuPolicy(Qt.CustomContextMenu) + horizontalHeader.customContextMenuRequested.connect(self.custom_context_menu) + + verticalHeader = table.verticalHeader() + verticalHeader.setContextMenuPolicy(Qt.ActionsContextMenu) + verticalHeader.setSectionResizeMode(QHeaderView.Fixed) + + copy_spectrum_values = QAction(self.COPY_ICON, "Copy", verticalHeader) + copy_spectrum_values.triggered.connect(self.presenter.action_copy_spectrum_values) + + delete_row = QAction(self.DELETE_ROW, "Delete Row", verticalHeader) + delete_row.triggered.connect(self.presenter.action_delete_row) + + separator2 = self.make_separator(verticalHeader) + + verticalHeader.addAction(copy_spectrum_values) + verticalHeader.addAction(separator2) + verticalHeader.addAction(delete_row) + + def custom_context_menu(self, position): + menu_main = QMenu() + plot = QMenu("Plot...", menu_main) + plot_line = QAction("Line", plot) + plot_line.triggered.connect(partial(self.presenter.action_plot, PlotType.LINEAR)) + + plot_line_with_yerr = QAction("Line with Y Errors", plot) + plot_line_with_yerr.triggered.connect(partial(self.presenter.action_plot, PlotType.LINEAR_WITH_ERR)) + + plot_scatter = QAction("Scatter", plot) + plot_scatter.triggered.connect(partial(self.presenter.action_plot, PlotType.SCATTER)) + + plot_line_and_points = QAction("Line + Symbol", plot) + plot_line_and_points.triggered.connect(partial(self.presenter.action_plot, PlotType.LINE_AND_SYMBOL)) + + plot.addAction(plot_line) + plot.addAction(plot_line_with_yerr) + plot.addAction(plot_scatter) + plot.addAction(plot_line_and_points) + menu_main.addMenu(plot) + + copy_bin_values = QAction(self.COPY_ICON, "Copy", menu_main) + copy_bin_values.triggered.connect(self.presenter.action_copy_bin_values) + + set_as_x = QAction("Set as X", menu_main) + set_as_x.triggered.connect(self.presenter.action_set_as_x) + + set_as_y = QAction("Set as Y", menu_main) + set_as_y.triggered.connect(self.presenter.action_set_as_y) + + set_as_none = QAction("Set as None", menu_main) + set_as_none.triggered.connect(self.presenter.action_set_as_none) + + statistics_on_columns = QAction("Statistics on Columns", menu_main) + statistics_on_columns.triggered.connect(self.presenter.action_statistics_on_columns) + + hide_selected = QAction("Hide Selected", menu_main) + hide_selected.triggered.connect(self.presenter.action_hide_selected) + + show_all_columns = QAction("Show All Columns", menu_main) + show_all_columns.triggered.connect(self.presenter.action_show_all_columns) + + sort_ascending = QAction("Sort Ascending", menu_main) + sort_ascending.triggered.connect(partial(self.presenter.action_sort_ascending, Qt.AscendingOrder)) + + sort_descending = QAction("Sort Descending", menu_main) + sort_descending.triggered.connect(partial(self.presenter.action_sort_ascending, Qt.DescendingOrder)) + + menu_main.addAction(copy_bin_values) + menu_main.addAction(self.make_separator(menu_main)) + menu_main.addAction(set_as_x) + menu_main.addAction(set_as_y) + + marked_y_cols = self.presenter.get_columns_marked_as_y() + num_y_cols = len(marked_y_cols) + + # If any columns are marked as Y then generate the set error menu + if num_y_cols > 0: + menu_set_as_y_err = QMenu("Set error for Y...") + for col in range(num_y_cols): + set_as_y_err = QAction("Y{}".format(col), menu_main) + # the column index of the column relative to the whole table, this is necessary + # so that later the data of the column marked as error can be retrieved + real_column_index = marked_y_cols[col] + # col here holds the index in the LABEL (multiple Y columns have labels Y0, Y1, YN...) + # this is NOT the same as the column relative to the WHOLE table + set_as_y_err.triggered.connect(partial(self.presenter.action_set_as_y_err, real_column_index, col)) + menu_set_as_y_err.addAction(set_as_y_err) + menu_main.addMenu(menu_set_as_y_err) + + menu_main.addAction(set_as_none) + menu_main.addAction(self.make_separator(menu_main)) + menu_main.addAction(statistics_on_columns) + menu_main.addAction(self.make_separator(menu_main)) + menu_main.addAction(hide_selected) + menu_main.addAction(show_all_columns) + menu_main.addAction(self.make_separator(menu_main)) + menu_main.addAction(sort_ascending) + menu_main.addAction(sort_descending) + + menu_main.exec_(self.mapToGlobal(position)) + + def make_separator(self, horizontalHeader): + separator1 = QAction(horizontalHeader) + separator1.setSeparator(True) + return separator1 + + @staticmethod + def copy_to_clipboard(data): + """ + Uses the QGuiApplication to copy to the system clipboard. + + :type data: str + :param data: The data that will be copied to the clipboard + :return: + """ + cb = QtGui.QGuiApplication.clipboard() + cb.setText(data, mode=cb.Clipboard) + + def ask_confirmation(self, message, title="Mantid Workbench"): + """ + :param message: + :return: + """ + reply = QMessageBox.question(self, title, message, QMessageBox.Yes, QMessageBox.No) + return True if reply == QMessageBox.Yes else False + + def show_warning(self, message, title="Mantid Workbench"): + QMessageBox.warning(self, title, message) diff --git a/qt/python/mantidqt/widgets/tableworkspacedisplay/workbench_table_widget_item.py b/qt/python/mantidqt/widgets/tableworkspacedisplay/workbench_table_widget_item.py new file mode 100644 index 0000000000000000000000000000000000000000..31086a8dd48ab43c1a9c538f16e665abe7890f70 --- /dev/null +++ b/qt/python/mantidqt/widgets/tableworkspacedisplay/workbench_table_widget_item.py @@ -0,0 +1,61 @@ +from qtpy.QtCore import Qt +from qtpy.QtWidgets import QTableWidgetItem + +from mantid.kernel import V3D + + +class WorkbenchTableWidgetItem(QTableWidgetItem): + def __init__(self, data, editable=False): + # if not editable just initialise the ItemWidget as string + if isinstance(data, V3D): + self.is_v3d = True + else: + self.is_v3d = False + + if not editable: + QTableWidgetItem.__init__(self, str(data)) + self.setFlags(self.flags() & ~Qt.ItemIsEditable) + return + + QTableWidgetItem.__init__(self) + + if isinstance(data, V3D): + data = str(data) + + self.display_data = data + # this will correctly turn all number cells into number types + self.reset() + + def _get_v3d_from_str(self, string): + if '[' in string and ']' in string: + string = string[1:-1] + if ',' in string: + return V3D(*[float(x) for x in string.split(',')]) + else: + raise RuntimeError("'{}' is not a valid V3D string.".format(string)) + + def __lt__(self, other): + """ + Overrides the comparison to other items. Used to provide correct sorting for types Qt doesn't handle + like V3D. Additionally, it makes sure strings are converted to floats for correct comparison. + + This is necessary because if the data is a float then it is stored as a string. + + :type other: WorkbenchTableWidgetItem + :param other: Other item that will be compared against + :return: + """ + if self.is_v3d: + return self._get_v3d_from_str(self.data(Qt.DisplayRole)) < self._get_v3d_from_str( + other.data(Qt.DisplayRole)) + try: + # if the data can be parsed as numbers then compare properly, otherwise default to the Qt implementation + return float(self.data(Qt.DisplayRole)) < float(other.data(Qt.DisplayRole)) + except: + return super(WorkbenchTableWidgetItem, self).__lt__(other) + + def reset(self): + self.setData(Qt.DisplayRole, self.display_data) + + def update(self): + self.display_data = self.data(Qt.DisplayRole)