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 &copy; 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 &copy; 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 &copy; 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 &copy; 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 &copy; 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 &copy; 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 &copy; 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 &copy; 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 &copy; 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 &copy; 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 &copy; 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)