Repository: Chris-N-K/napari-time-series-plotter (language: python)

Issue:
Update widget GUI
Change the widget GUI for more convenient usage.

TSP should be callable as one widget from the viewer, so the user must not activate all widgets separately.<br>
A toolbar for interaction with the figure will add functionality (save figure to file)

TODO:
- [x] Create TSP main widget containing all sub-widgets
- [x] Add the Matplotlib Toolbar to the plotter widget


Discussion / hints:

Fix patch:
diff --git a/setup.cfg b/setup.cfg
index 042bad3..e0624d9 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -32,14 +32,20 @@ package_dir =
     =src
 # add your package requirements here
 install_requires =
-    matplotlib>=3.4.3
-    napari>=0.4.12
-    napari-plugin-engine==0.2.0
-    numpy>=1.21.4
-    pytest>=6.2.5
-    qtpy>=1.11.2
+    napari-plugin-engine>=0.2.0
+    napari_matplotlib
+    numpy
+    qtpy
+    pyqt5
 include_package_data = True
 
+[options.extras_require]
+test =
+    napari
+    pytest
+    pytest-qt
+    pytest-cov
+
 [options.packages.find]
 where = src
 
@@ -50,3 +56,6 @@ napari.manifest =
 [options.package_data]
 * = *.yaml
 
+[coverage:run]
+omit =
+    src/napari_time_series_plotter/run_plugin.py
diff --git a/src/napari_time_series_plotter/__init__.py b/src/napari_time_series_plotter/__init__.py
index e69de29..9b80aea 100644
--- a/src/napari_time_series_plotter/__init__.py
+++ b/src/napari_time_series_plotter/__init__.py
@@ -0,0 +1,3 @@
+from .dock_widget import *
+from .widgets import *
+from .utils import *
diff --git a/src/napari_time_series_plotter/_dock_widget.py b/src/napari_time_series_plotter/_dock_widget.py
deleted file mode 100644
index df15293..0000000
--- a/src/napari_time_series_plotter/_dock_widget.py
+++ /dev/null
@@ -1,256 +0,0 @@
-"""
-This module is a QWidget plugin for napari.
-
-The plugin is made of two subunits the LayerSelector and VoxelPlotter widget.
-Together they give the user the ability to select 3D or 4D image layers in the viewer and plot voxel values.
-The VoxelPlotter visualises the values of a voxel along the first axis as a graph. If multiple layers are selected
-multiple graphs are plotted to the same figure, docked to the viewer. The user selects the voxel by hovering with the
-mouse over it while holding shift.
-
-Non image layers or layers with more ore less dimensions will be ignored by the LayerSelector.
-
-The widgets are send to the viewer through the ``napari_experimental_provide_dock_widget`` hook specification.
-see: https://napari.org/docs/dev/plugins/hook_specifications.html
-"""
-
-import matplotlib
-import napari.layers
-import numpy as np
-from matplotlib.backends.backend_qt5agg import FigureCanvas
-from matplotlib.figure import Figure
-from napari.layers.image import Image
-from qtpy.QtCore import Signal, Slot, QObject
-from qtpy.QtWidgets import QDialog, QWidget, QVBoxLayout
-
-from ._widgets import TSPCheckBox
-
-# from matplotlib.backends.backend_qt5agg import NavigationToolbar2QT as NavigationToolbar
-
-matplotlib.use('QT5Agg')
-
-# Thanks a lot to Grzegorz Bokota for pointing out this solution for connecting the widgets!
-_sync = None
-
-
-class Sync(QObject):
-    """
-    Synchronisation class for widget to widget communication. Wraps a qtpy.QCore.Signal object. The signal instance
-    accepts a list as emit input.
-    """
-    signal = Signal([list])
-
-
-def get_sync():
-    """Registers a Sync instance to a QWidget. If there is no global Sync instance a new will be spawned.
-
-    Generates a global Sync object if none exists. Otherwise returns the global one.
-
-    :returns: napari_time_series_plotter._dock_widget.Sync
-    """
-    global _sync
-    if _sync is None:
-        _sync = Sync()
-    return _sync
-############################################################################################
-
-
-class LayerSelector(QWidget):
-    """QWidget for layer selection.
-
-    This class generates a pyqt.QWidget containing pyqt.QCheckBoxes for each image layer with three or four dimension in
-    the current napari viewer instance. The widget is connected to napari viewer events, thus pyqt.QCheckBoxes are
-    removed or added to the widget if valid layers are added or removed. The class stores references to layers of
-    checked boxes in self.selected_layers for access from the outside.
-    The class connects to an instance of the Sync class for widget to widget communication.
-    """
-    def __init__(self, napari_viewer):
-        """Initialise instance.
-
-        :param napari_viewer: Napari viewer instance, input should be handled by the napari_hook_implementation decoration
-        :type napari_viewer: napari.viewer.Viewer
-        """
-        super().__init__()
-        self.viewer = napari_viewer
-        self.selected_layers = []
-        self._cboxes = []
-        self.sync = get_sync()
-        self._init_ui()
-
-        # connect checkbox management with viewer layer events
-        self.viewer.layers.events.removed.connect(self._remove_cb)
-        self.viewer.layers.events.inserted.connect(self._add_cb)
-
-    def _init_ui(self):
-        """Initialise the LayerSelector widget UI.
-
-        This method generates a pyqt.QVBoxLayout and executes the _add_cb() method to populate it with pyqt.QCheckboxes.
-        _add_cb() is connected to the napari.Viewer.layers.events.inserted event and _remove_cb() to
-        napari.Viewer.layers.event.removed event.
-        """
-        # creat outer surroundings
-        self.vbox = QVBoxLayout()
-
-        # create check boxes
-        self.setLayout(self.vbox)
-        self.setWindowTitle('Layer Selector')
-        self._add_cb(None)
-
-    def _add_cb(self, event):
-        """Add a TSP_CheckBox to the instance.
-
-        The method scans the LayerSelector object for active pyqt.QCheckboxes and the list of layers in the napari
-        viewer instance. For layers not corresponding to any existing pyqt.QCheckboxes new widgets are added.
-        State change events of the checkboxes are connected to the internal _check_states() function.
-        """
-        reg_layers = [cb.layer for cb in self._cboxes]
-        for layer in self.viewer.layers:
-            if all([layer not in reg_layers, isinstance(layer, Image), 5 > layer.ndim > 2]):
-                cb = TSPCheckBox(layer)
-                cb.stateChanged.connect(self._check_states)
-                cb.layer.events.name.connect(cb.rename)
-                self.vbox.addWidget(cb)
-                self._cboxes.append(cb)
-
-    def _remove_cb(self, event):
-        """Method to add a pyqt.QCheckBox to the LayerSelector object.
-
-        The method scans the LayerSelector object for active pyqt.QCheckboxes and the list of layers in
-        the napari viewer instance. Any pyqt.QCheckboxes not corresponding to viewer layers are removed.
-        The method accepts additional arguments to be compatible with napari viewer event hooks.
-        """
-        for cb in self._cboxes:
-            if cb.layer not in self.viewer.layers:
-                cb.deleteLater()
-                self._cboxes.remove(cb)
-
-    def _check_states(self, event):
-        """Method to check pyqt.QCheckbox states.
-
-        This method curates the attribute selected_layers of LayerSelector objects. If called all pyqt.QCheckboxes
-        registered in the internal _cboxes list are checked for there current state. If a box's state is checked the
-        corresponding napari viewer layers is added to the list of selected layers.
-        """
-        self.selected_layers = [cb.layer for cb in self._cboxes if cb.isChecked()]
-        self.sync.signal.emit(self.selected_layers)
-
-
-class VoxelPlotter(QDialog):
-    """QWidget for plotting.
-
-    This class generates a matplotlib figure canvas and binds it into a QDialog. The canvas and artists are accessible
-    by the attributes self.canvas, self.fig and self.ax. By calling the internal method self._plot_voxel_callback() the
-    cursor position will be translated into a data series and plotted to the canvas. Data will be extracted only from
-    viewer layers registered in self.selected_layers.
-    Upon initialisation the class connects (self._sync) to an instance of the Sync class for widget to widget
-    communication. Every time an instance of the class receives a signal through self._sync self.selected_layers will be
-    updated.
-    """
-    def __init__(self, napari_viewer, parent=None):
-        """Initialise instance.
-
-        :param napari_viewer: Napari viewer instance, input should be handled by the napari_hook_implementation decoration
-        :type napari_viewer: napari.viewer.Viewer
-        :param parent: Parent widget, optional
-        :type parent: qtpy.QtWidgets.QWidget
-        """
-        super(VoxelPlotter, self).__init__(parent)
-        self.viewer = napari_viewer
-        self.selected_layers = []
-        self.sync = get_sync()
-        self._init_mpl_widgets()
-
-        # connect callbacks
-        self.sync.signal.connect(self._update_layer_selection)
-        self.viewer.mouse_move_callbacks.append(self._plot_voxel_callback)
-
-    def _init_mpl_widgets(self):
-        """Method to initialise a matplotlib figure canvas and the VoxelPlotter UI.
-
-        This method generates a matplotlib.backends.backend_qt5agg.FigureCanvas and populates it with a
-        matplotlib.figure.Figure and further matplotlib artists. The canvas is added to a QVBoxLayout afterwards.
-        """
-        # set up figure and axe objects
-        self.fig = Figure()
-        self.canvas = FigureCanvas(self.fig)
-        # TODO: find a way to include the toolbar with a compatible style
-        # self.toolbar = NavigationToolbar(self.canvas, self)
-        # self.toolbar.setStyleSheet("color:Black;")
-        self.ax = self.fig.add_subplot(111)
-        self.ax.annotate('Hold "Shift" while moving over the image'
-                         '\nto plot pixel signal over time',
-                         (0.5, 0.5),
-                         ha='center',
-                         va='center',
-                         size=15,
-                         bbox=dict(facecolor=(0.9, 0.9, 0.9), alpha=1, boxstyle='square'))
-
-        # construct layout
-        layout = QVBoxLayout()
-        # layout.addWidget(self.toolbar)
-        layout.addWidget(self.canvas)
-        self.setLayout(layout)
-        self.setWindowTitle('Voxel Plotter')
-
-    @Slot(list)
-    def _update_layer_selection(self, selected_layers):
-        """Method for updating the selected_layers attribute.
-
-        :param selected_layers: list of napari image layers
-        :type selected_layers: list
-        """
-        self.selected_layers = selected_layers
-
-    def _extract_voxel_time_series(self, cpos, nlayer):
-        """Method to extract the array element values along the first axis of a napari viewer layer.
-
-        First the data array is extracted from a napari image layer and the cursor position is
-        translated into an array index. If the index points to an element inside of the array all values along the first
-        axis are returned as a list, otherwise None is returned.
-
-        :param cpos: Position of the cursor inside of a napari viewer widget.
-        :type cpos: numpy.ndarray
-        :param nlayer: Napari image layer to extract data from.
-        :type nlayer: napari.layers.image.Image
-        """
-        # get full data array from layer
-        data = nlayer.data
-        # convert cursor position to index
-        ind = tuple(map(int, np.round(nlayer.world_to_data(cpos))))
-        # return extracted data if index matches array
-        if all([0 <= i < max_i for i, max_i in zip(ind, data.shape)]):
-            return ind, data[(slice(None),) + ind[1:]]
-        return ind, None
-
-    def _plot_voxel_callback(self, viewer, event):
-        """Mouse movement callback method for plotting voxel time series.
-
-        If "Shift" is in the event modifiers the cursor position is translated into an index for the layer data.
-        For all selected layers (VoxelPlotter.selected_layers) the elements of the first axis at that position are
-        extracted. The data series and a figure legend are plotted to the VoxelPlotter figure canvas.
-
-        :param viewer: Napari viewer instance, handled by napari.viewer.mouse_move_callbacks
-        :type viewer: napari.viewer.Viewer
-        :param event: Napari viewer event, handled by napari.viewer.mouse_move_callbacks
-        """
-        if 'Shift' in event.modifiers:
-            # get cursor position from viewer
-            cursor_pos = np.round(viewer.cursor.position)
-            # clear active plot
-            self.ax.clear()
-            # add new graphs
-            for layer in self.selected_layers:
-                # get layer data
-                lname = layer.name
-
-                # extract voxel time series
-                ind, vts = self._extract_voxel_time_series(cursor_pos, layer)
-                if not isinstance(vts, type(None)):
-                    # add graph
-                    self.ax.plot(vts, label=lname)
-                    self.ax.set_title(f'Series [:, {str(ind[1:]).replace("(","").replace(")", "")}]')
-                    self.ax.set_xlabel('Time')
-                    self.ax.set_ylabel('Pixel / Voxel Values')
-
-            # redraw figure
-            self.ax.legend(loc=1)
-            self.fig.canvas.draw()
diff --git a/src/napari_time_series_plotter/_widgets.py b/src/napari_time_series_plotter/_widgets.py
deleted file mode 100644
index 2c1ab7d..0000000
--- a/src/napari_time_series_plotter/_widgets.py
+++ /dev/null
@@ -1,24 +0,0 @@
-from qtpy.QtWidgets import QCheckBox
-
-
-class TSPCheckBox(QCheckBox):
-    """CheckBox widget class derived from qtpy.QtWidgets.QCheckBox
-
-    Adds the attribute layer and the event connectable method rename to the original class.
-    """
-    def __init__(self, layer):
-        """Instance initialisation.
-
-        :param layer: Connected napari viewer layer
-        :type layer: napari.layers.Image
-        """
-        super().__init__()
-        self.layer = layer
-        self.setText(layer.name)
-
-    def rename(self, event):
-        """Rename check box upon layer name change.
-
-        Sets the instance attribute `text` to self.layer.name. Should be connected to the layers `name` event.
-        """
-        self.setText(self.layer.name)
diff --git a/src/napari_time_series_plotter/dock_widget.py b/src/napari_time_series_plotter/dock_widget.py
new file mode 100644
index 0000000..ec1b2b4
--- /dev/null
+++ b/src/napari_time_series_plotter/dock_widget.py
@@ -0,0 +1,57 @@
+"""
+This module is a QWidget plugin for napari.
+
+The plugin is made of two subunits the LayerSelector and VoxelPlotter widget.
+Together they give the user the ability to select 3D or 4D image layers in the viewer and plot voxel values.
+The VoxelPlotter visualises the values of a voxel along the first axis as a graph. If multiple layers are selected
+multiple graphs are plotted to the same figure, docked to the viewer. The user selects the voxel by hovering with the
+mouse over it while holding shift.
+
+Non image layers or layers with more ore less dimensions will be ignored by the LayerSelector.
+
+The widgets are send to the viewer through the ``napari_experimental_provide_dock_widget`` hook specification.
+see: https://napari.org/docs/dev/plugins/hook_specifications.html
+"""
+
+from qtpy import QtWidgets
+
+from .widgets import *
+
+__all__ = ('TSPExplorer',)
+
+
+class TSPExplorer(QtWidgets.QWidget):
+    """napari_time_series_plotter main widget.
+
+    Contains the sub-widgets LayerSelector and VoxelPlotter and is meant to be docked to the napari viewer.
+
+    Attributes:
+        viewer : napari.Viewer
+        tabs : QtWidgets.QTabWidget
+        selector : napari_time_series_plotter.LayerSelector
+        plotter : napari_time_series_plotter.VoxelPlotter
+    """
+    def __init__(self, napari_viewer, parent=None):
+        super(TSPExplorer, self).__init__(parent)
+        self.viewer = napari_viewer
+
+        # subwidgets
+        self.tabs = QtWidgets.QTabWidget()
+        self.selector = LayerSelector(self.viewer)
+        self.options = OptionsManager()
+        self.plotter = VoxelPlotter(self.viewer, self.selector, self.options.plotter_options())
+        self.tabs.addTab(self.plotter, 'Plotter')
+        self.tabs.addTab(self.options, 'Options')
+
+        # layout
+        layout = QtWidgets.QVBoxLayout()
+        layout.addWidget(self.tabs)
+        layout.addWidget(QtWidgets.QLabel('Layer Selector'))
+        layout.addWidget(self.selector)
+        self.setLayout(layout)
+
+        # callbacks
+        self.selector.model().itemChanged.connect(self.plotter.update_layers)
+        self.viewer.layers.events.inserted.connect(self.selector.update_model)
+        self.viewer.layers.events.removed.connect(self.selector.update_model)
+        self.options.plotter_option_changed.connect(self.plotter.update_options)
diff --git a/src/napari_time_series_plotter/napari.yaml b/src/napari_time_series_plotter/napari.yaml
index ce63a75..df05c81 100644
--- a/src/napari_time_series_plotter/napari.yaml
+++ b/src/napari_time_series_plotter/napari.yaml
@@ -2,14 +2,9 @@ name: napari-time-series-plotter
 schema_version: 0.1.0
 contributions:
   commands:
-    - id: napari-time-series-plotter.LayerSelector
-      title: Create Layer Selector
-      python_name: napari_time_series_plotter._dock_widget:LayerSelector
-    - id: napari-time-series-plotter.VoxelPlotter
-      title: Create Voxel Plotter
-      python_name: napari_time_series_plotter._dock_widget:VoxelPlotter
+    - id: napari-time-series-plotter.TSPExplorer
+      title: Create TSPExplorer widget
+      python_name: napari_time_series_plotter.dock_widget:TSPExplorer
   widgets:
-    - command: napari-time-series-plotter.LayerSelector
-      display_name: Layer Selector
-    - command: napari-time-series-plotter.VoxelPlotter
-      display_name: Voxel Plotter
+    - command: napari-time-series-plotter.TSPExplorer
+      display_name: TSPExplorer
diff --git a/src/napari_time_series_plotter/utils.py b/src/napari_time_series_plotter/utils.py
new file mode 100644
index 0000000..f2acb14
--- /dev/null
+++ b/src/napari_time_series_plotter/utils.py
@@ -0,0 +1,90 @@
+import numpy as np
+
+from qtpy import QtCore, QtGui
+from qtpy.QtCore import Qt
+
+__all__ = ('get_valid_image_layers', 'extract_voxel_time_series', 'SelectorListItem', 'SelectorListModel')
+
+
+# functions
+def get_valid_image_layers(layer_list):
+    """
+    Extract napari images layers of 3 or more dimensions from the input list.
+    """
+    out = [layer for layer in layer_list if layer._type_string == 'image' and layer.data.ndim >= 3]
+    out.reverse()
+    return out
+
+
+def extract_voxel_time_series(cpos, layer):
+    """Extract the array element values along the first axis of a napari viewer layer.
+
+    First the data array is extracted from a napari image layer and the cursor position is
+    translated into an array index. If the index points to an element inside of the array all values along the first
+    axis are returned as a list, otherwise None is returned.
+    :param cpos: Position of the cursor inside of a napari viewer widget.
+    :type cpos: numpy.ndarray
+    :param layer: Napari image layer to extract data from.
+    :type layer: napari.layers.image.Image
+    """
+    # get full data array from layer
+    data = layer.data
+    # convert cursor position to index
+    ind = tuple(map(int, np.round(layer.world_to_data(cpos))))
+    # return extracted data if index matches array
+    if all([0 <= i < max_i for i, max_i in zip(ind, data.shape)]):
+        return data[(slice(None),) + ind[1:]]
+
+
+# classes
+class SelectorListItem(QtGui.QStandardItem):
+    """Subclass of QtGui.QStandardItem for usage in the napari_time_series_plotter.SelectorListModel.
+
+    Each item is checkable and holds the reference to a napari layer. The text of the item is bound to the layer name
+    and updates with it.
+
+    Attributes:
+        layer : napari.layers.Image
+    """
+    def __init__(self, napari_layer):
+        super().__init__()
+        self.layer = napari_layer
+        self.setText(self.layer.name)
+        self.setCheckable(True)
+        self.setCheckState(Qt.Unchecked)
+        self.layer.events.name.connect(self._layer_name_changed)
+
+    def type(self):
+        """
+        Return custom type code.
+        """
+        return 1001
+
+    def _layer_name_changed(self, event):
+        """
+        Receiver for napari layer.events.name event. Updates the item text according to the new layer name.
+        """
+        self.setText(self.layer.name)
+
+
+class SelectorListModel(QtGui.QStandardItemModel):
+    """Subclass of QtGui.QStandardItemModel.
+
+    Automatically builds from a list of QtGui.QStandardItems or derivatives.
+    """
+    def __init__(self, items=None):
+        super().__init__()
+        if items:
+            for item in items:
+                self.appendRow(item)
+
+    def get_checked(self):
+        """
+        Return all items with state QtCore.Qt.Checked.
+        """
+        checked = []
+        for index in range(self.rowCount()):
+            item = self.item(index)
+            if item.checkState() == QtCore.Qt.Checked:
+                checked.append(item.layer)
+        return checked
diff --git a/src/napari_time_series_plotter/widgets.py b/src/napari_time_series_plotter/widgets.py
new file mode 100644
index 0000000..2f7564b
--- /dev/null
+++ b/src/napari_time_series_plotter/widgets.py
@@ -0,0 +1,228 @@
+import numpy as np
+
+from napari_matplotlib.base import NapariMPLWidget
+from qtpy import QtCore, QtGui, QtWidgets
+
+from .utils import *
+
+__all__ = ('LayerSelector', 'VoxelPlotter', 'OptionsManager')
+
+
+class LayerSelector(QtWidgets.QListView):
+    """Subclass of QListView for selection of 3D/4D napari image layers.
+
+    This widget contains a list of all 4D images layer currently in the napari viewer layers list. All items have a
+    checkbox for selection. It is not meant to be directly docked to the napari viewer, but needs a napari viewer
+    instance to work.
+
+    Attributes:
+        napari_viewer : napari.Viewer
+        parent : Qt parent widget / window, default None
+    """
+    def __init__(self, napari_viewer, model=SelectorListModel(), parent=None):
+        super(LayerSelector, self).__init__(parent)
+        self.napari_viewer = napari_viewer
+        self.setModel(model)
+        self.update_model(None)
+
+    def update_model(self, event):
+        """
+        Update the underlying model data (clear and rewrite) and emit an itemChanged event.
+        The size of the widget is adjusted to the number of items displayed.
+
+        :param event: Not used, just for napari event compatibility.
+        """
+        self.model().clear()
+        for layer in get_valid_image_layers(self.napari_viewer.layers):
+            item = SelectorListItem(layer)
+            self.model().appendRow(item)
+        self.setMaximumHeight(
+            self.sizeHintForRow(0) * self.model().rowCount() + 2 * self.frameWidth())
+        self.model().itemChanged.emit(QtGui.QStandardItem())
+
+
+class VoxelPlotter(NapariMPLWidget):
+    """Subclass of napari_matplotlib NapariMPLWidget for voxel position based time series plotting.
+
+    This widget contains a matplotlib figure canvas for plot visualisation and the matplotlib toolbar for easy option
+    controls. The widget is not meant for direct docking to the napari viewer.
+    Plot visualisation is triggered by moving the mouse cursor over the voxels of an image layer while holding the shift
+    key. The first dimension is handled as time. This widget needs a napari viewer instance and a LayerSelector instance
+    to work properly.
+
+    Attributes:
+        axes : matplotlib.axes.Axes
+        selector : napari_time_series_plotter.LayerSelector
+        cursor_pos : tuple of current mouse cursor position in the napari viewer
+    """
+    def __init__(self, napari_viewer, selector, options=None):
+        super().__init__(napari_viewer)
+        self.selector = selector
+        self.axes = self.canvas.figure.subplots()
+        self.cursor_pos = np.array([])
+        self.update_layers(None)
+        if options:
+            self.update_options(options)
+        else:
+            self.autoscale = True
+            self.x_lim = (None, None)
+            self.y_lim = (None, None)
+            self.max_label_len = None
+
+    def clear(self):
+        """
+        Clear the canvas.
+        """
+        self.axes.clear()
+
+    def draw(self):
+        """
+        Draw a value over time line plot for the voxels of all layers in the layer attribute at the position stored
+        in the cursor_pos attribute. The first dimension is handled as time.
+        """
+        handles = []
+        if self.layers and self.cursor_pos.size != 0:
+            # add new graphs
+            for layer in self.layers:
+                # get layer data
+                if self.max_label_len:
+                    lname = layer.name[slice(self.max_label_len)]
+                else:
+                    lname = layer.name
+                # extract voxel time series
+                vts = extract_voxel_time_series(self.cursor_pos, layer)
+                if not isinstance(vts, type(None)):
+                    # add graph
+                    handles.extend(self.axes.plot(vts, label=lname))
+        if handles:
+            self.axes.set_title(f'Position: {self.cursor_pos}')
+            self.axes.tick_params(
+                axis='both',  # changes apply to the x-axis
+                which='both',  # both major and minor ticks are affected
+                bottom=True,  # ticks along the bottom edge are off
+                top=False,  # ticks along the top edge are off
+                labelbottom=True,
+                left=True,
+                right=False,
+                labelleft=True,
+            )
+            self.axes.set_xlabel('Time')
+            self.axes.set_ylabel('Pixel / Voxel Values')
+            if not self.autoscale:
+                self.axes.set_xlim(self.x_lim)
+                self.axes.set_ylim(self.y_lim)
+            self.axes.legend(loc=1)
+        else:
+            self.axes.annotate(
+                'Hold "Shift" while moving the cursor\nover a selected layer\nto plot pixel / voxel time series',
+                (0.5, 0.5),
+                ha='center',
+                va='center',
+                size=15,
+            )
+            self.axes.tick_params(
+                axis='both',  # changes apply to the x-axis
+                which='both',  # both major and minor ticks are affected
+                bottom=False,  # ticks along the bottom edge are off
+                top=False,  # ticks along the top edge are off
+                labelbottom=False,
+                left=False,
+                right=False,
+                labelleft=False,
+            )
+
+    def update_layers(self, event):
+        """
+        Overwrite the layers attribute with the currently checked items in the selector model and re-draw.
+        """
+        self.layers = self.selector.model().get_checked()
+        self._draw()
+
+    def setup_callbacks(self):
+        """
+        Setup callbacks for:
+         - mouse move inside of the napari viewer
+         - dim step changes
+        """
+        self.viewer.mouse_move_callbacks.append(self._shift_move_callback)
+        self.viewer.dims.events.current_step.connect(self._draw)
+
+    def _shift_move_callback(self, viewer, event):
+        """Receiver for napari.viewer.mouse_move_callbacks, checks for 'Shift' event modifier.
+
+        If event contains 'Shift' and layer attribute contains napari layers the cursor position is written to the
+        cursor_pos attribute and the _draw method is called afterwards.
+        """
+        if 'Shift' in event.modifiers and self.layers:
+            self.cursor_pos = np.round(viewer.cursor.position)
+            self._draw()
+
+    def update_options(self, options_dict):
+        self.autoscale = options_dict['autoscale']
+        self.x_lim = options_dict['x_lim']
+        self.y_lim = options_dict['y_lim']
+        if options_dict['truncate']:
+            self.max_label_len = options_dict['trunc_len']
+        else:
+            self.max_label_len = None
+        self._draw()
+
+
+class OptionsManager(QtWidgets.QWidget):
+    # signals
+    plotter_option_changed = QtCore.Signal(dict)
+
+    def __init__(self):
+        super().__init__()
+        # subwidgets
+        self.label_plotter_options = QtWidgets.QLabel('Plotter Options')
+        self.label_plotter_options.setStyleSheet(" font-weight: bold; text-decoration: underline; ")
+        self.cb_autoscale = QtWidgets.QCheckBox()
+        self.cb_autoscale.setChecked(True)
+        self.le_autoscale_x_min = QtWidgets.QLineEdit()
+        self.le_autoscale_x_max = QtWidgets.QLineEdit()
+        self.le_autoscale_y_min = QtWidgets.QLineEdit()
+        self.le_autoscale_y_max = QtWidgets.QLineEdit()
+        self.cb_trunc = QtWidgets.QCheckBox()
+        self.cb_trunc.setChecked(False)
+        self.le_trunc = QtWidgets.QLineEdit()
+
+        # connect callbacks
+        # plotter options
+        self.cb_autoscale.stateChanged.connect(self.poc_callback)
+        self.le_autoscale_x_min.editingFinished.connect(self.poc_callback)
+        self.le_autoscale_x_max.editingFinished.connect(self.poc_callback)
+        self.le_autoscale_y_min.editingFinished.connect(self.poc_callback)
+        self.le_autoscale_y_max.editingFinished.connect(self.poc_callback)
+        self.cb_trunc.stateChanged.connect(self.poc_callback)
+        self.le_trunc.editingFinished.connect(self.poc_callback)
+
+        # layout
+        layout = QtWidgets.QFormLayout()
+        layout.addRow(self.label_plotter_options)
+        layout.addRow('Auto scale plot axes', self.cb_autoscale)
+        layout.addRow('x_min', self.le_autoscale_x_min)
+        layout.addRow('x_max', self.le_autoscale_x_max)
+        layout.addRow('y_min', self.le_autoscale_y_min)
+        layout.addRow('y_max', self.le_autoscale_y_max)
+        layout.addRow('Truncate layer names', self.cb_trunc)
+        layout.addRow('max length', self.le_trunc)
+        self.setLayout(layout)
+
+    def poc_callback(self):
+        self.plotter_option_changed.emit(self.plotter_options())
+
+    def plotter_options(self):
+        return dict(
+            autoscale=self.cb_autoscale.isChecked(),
+            x_lim=(
+                int(self.le_autoscale_x_min.text()) if self.le_autoscale_x_min.text() else None,
+                int(self.le_autoscale_x_max.text()) if self.le_autoscale_x_max.text() else None,
+            ),
+            y_lim=(
+                int(self.le_autoscale_y_min.text()) if self.le_autoscale_y_min.text() else None,
+                int(self.le_autoscale_y_max.text()) if self.le_autoscale_y_max.text() else None,
+            ),
+            truncate=self.cb_trunc.isChecked(),
+            trunc_len=int(self.le_trunc.text()) if self.le_trunc.text() else None,
+        )
diff --git a/tox.ini b/tox.ini
index fc8b638..37101ff 100644
--- a/tox.ini
+++ b/tox.ini
@@ -30,9 +30,9 @@ deps =
     pytest  # https://docs.pytest.org/en/latest/contents.html
     pytest-cov  # https://pytest-cov.readthedocs.io/en/latest/
     # you can remove these if you don't use them
-    matplotlib
     napari
     napari_plugin_engine
+    napari_matplotlib
     numpy
     pytest-qt
     qtpy


Test patch:
diff --git a/src/napari_time_series_plotter/_tests/test_dock_widget.py b/src/napari_time_series_plotter/_tests/test_dock_widget.py
index 37246e3..adead6a 100644
--- a/src/napari_time_series_plotter/_tests/test_dock_widget.py
+++ b/src/napari_time_series_plotter/_tests/test_dock_widget.py
@@ -1,192 +1,32 @@
 """
 Napari-time_series_plotter test module.
 """
-import pytest
+from pytestqt import qtbot
+from qtpy import QtWidgets
 
-from napari_time_series_plotter._dock_widget import *
+from ..dock_widget import TSPExplorer
+from ..widgets import LayerSelector, VoxelPlotter, OptionsManager
 
 
-# fixture for LayerSelector class tests
-@pytest.fixture
-def selector(make_napari_viewer):
-    _sync = None
+def test_TSPExplorer(make_napari_viewer, qtbot: qtbot):
     viewer = make_napari_viewer(show=False)
-    viewer.add_image(np.random.rand(10, 10, 10), name='3D')
-    yield LayerSelector(viewer)
+    explorer = TSPExplorer(viewer)
 
+    # test plotter
+    plotter = explorer.findChild(VoxelPlotter)
+    assert plotter
 
-# fixture for VoxelPlotter class tests
-@pytest.fixture
-def plotter(make_napari_viewer):
-    _sync = None
-    viewer = make_napari_viewer(show=False)
-    viewer.add_image(np.random.rand(10, 10, 10), name='3D')
-    viewer.add_image(np.random.rand(10, 10, 10, 10), name='4D')
-    yield VoxelPlotter(viewer)
-
-
-# test fixtures
-def test_selector(selector: LayerSelector):
-    assert selector.windowTitle() == 'Layer Selector'
-    # the widget should already contain four checkboxes named after the layers of viewer
-    layers = [layer.name for layer in selector.viewer.layers]
-    cboxes = [cb.text() for cb in selector._cboxes]
-    assert all([lname == cbname for lname, cbname in zip(layers, cboxes)])
-
-    # Sync instance generated?
-    assert isinstance(selector.sync, Sync)
-
-
-def test_plotter(plotter: VoxelPlotter):
-    # spawned mpl_widget and added to viewer?
-    assert plotter.windowTitle() == 'Voxel Plotter'
-
-    # Sync instance generated?
-    assert isinstance(plotter.sync, Sync)
-
-
-# test LayerSelector subunits
-def test_selector_add_layer(selector: LayerSelector):
-    # should start with one checkbox
-    assert len(selector._cboxes) == 1
-
-    # adding a 4D image layer should trigger checkbox generation
-    selector.viewer.add_image(np.random.rand(10, 10, 10, 10), name='4D')
-    assert len(selector._cboxes) == 2
-
-    # 2D and 5D images layers as well as other layer types should not trigger checkbox generation
-    selector.viewer.add_image(np.random.rand(10, 10), name='2D')
-    selector.viewer.add_image(np.random.rand(10, 10, 10, 10, 10), name='5D')
-    selector.viewer.add_labels(np.random.randint(10, size=(10, 10, 10)), name='3D_labels')
-    assert len(selector._cboxes) == 2
-
-
-def test_selector_remove_layer(selector: LayerSelector):
-    # should start with one checkbox
-    assert len(selector._cboxes) == 1
-
-    # removing a layer should delete the corresponding checkbox
-    selector.viewer.layers.remove('3D')
-    assert len(selector._cboxes) == 0
-
-
-def test_selector_selection_response(selector: LayerSelector):
-    # initially no boxes should be checked and selected_layers should be empty
-    assert not all([cb.isChecked() for cb in selector._cboxes])
-    assert not selector.selected_layers
-
-    # if a box is checked a signal should be emitted
-    def _receiver_mock(signal):
-        assert signal == selector.selected_layers
-        assert '3D' == signal[0].name
-
-    selector.sync.signal.connect(_receiver_mock)
-    for cb in selector._cboxes:
-        if cb.text() == '3D':
-            cb.setChecked(True)
-
-
-# test VoxelPlotter subunits
-def test_plotter_layer_selection_update(plotter: VoxelPlotter):
-    # at start no layers should be selected
-    assert not plotter.selected_layers
-
-    # TODO: find better solution for signal test
-    # if a signal was detected selected_layers should be updated
-    layers = plotter.viewer.layers
-    mock_selected_layers = [layers['3D'], layers['4D']]
-    plotter._update_layer_selection(mock_selected_layers)
-    assert plotter.selected_layers == mock_selected_layers
-
-
-def test_plotter_plot_voxel_callback(plotter: VoxelPlotter, monkeypatch):
-    # mock classes
-    class Event:
-        modifiers = {'Shift'}
-
-    class Layer:
-        name = '4D'
-
-    class Cursor:
-        position = [0, 3, 12, 17]
-
-    class Viewer:
-        cursor = Cursor
-
-    event = Event()
-    layer = Layer()
-    viewer = Viewer()
-
-    monkeypatch.setattr(plotter, 'selected_layers', [layer])
-
-    # mock functions
-    def mock_extract_voxel_time_series_pos(*args):
-        return (0, 3, 12, 17), list(range(10))
-
-    def mock_extract_voxel_time_series_neg(*args):
-        return (0, 3, 12, 17), None
-
-    # should have start up figure with empty axes
-    assert not plotter.ax.get_title()
-    assert not plotter.ax.get_xlabel()
-    assert not plotter.ax.get_ylabel()
-
-    # _extract_voxel_time_series data return should draw new axes
-    monkeypatch.setattr(plotter, '_extract_voxel_time_series', mock_extract_voxel_time_series_pos)
-    plotter._plot_voxel_callback(viewer, event)
-    assert plotter.ax.get_title() == 'Series [:, 3, 12, 17]'
-    assert plotter.ax.get_xlabel() == 'Time'
-    assert plotter.ax.get_ylabel() == 'Pixel / Voxel Values'
-    assert len(plotter.ax.get_legend().get_texts())
-
-    # _extract_voxel_time_series data return should delete axes
-    monkeypatch.setattr(plotter, '_extract_voxel_time_series', mock_extract_voxel_time_series_neg)
-    plotter._plot_voxel_callback(viewer, event)
-    assert not plotter.ax.get_title()
-    assert not plotter.ax.get_xlabel()
-    assert not plotter.ax.get_ylabel()
-    assert not len(plotter.ax.get_legend().get_texts())
-
-
-def test_extract_voxel_time_series(plotter: VoxelPlotter, monkeypatch):
-    # mock cursor position
-    cursor_pos_1 = np.array([3.2, 7.1, 2.9, 1.0])
-    cursor_pos_2 = np.array([3.1, -3.4, 5.0, 2.7])
-
-    # select layers
-    layer = plotter.viewer.layers['4D']
-
-    # mock data at [:, 7, 3, 1]
-    mock_data = [i for i in range(layer.data.shape[0])]
-    layer.data[:, 7, 3, 1] = mock_data
-
-    # extracted data should be equal mock data
-    ind, vts = plotter._extract_voxel_time_series(cursor_pos_1, layer)
-    assert ind == (3, 7, 3, 1)
-    assert np.all(vts == mock_data)
-
-    # cursor position outside of the array index should yield no data
-    ind, vts = plotter._extract_voxel_time_series(cursor_pos_2, layer)
-    assert ind == (3, -3, 5, 3)
-    assert not vts
-
+    # test options
+    options = explorer.findChild(OptionsManager)
+    assert options
 
-# test functions
-def test_tspcheckbox():
-    class MockLayer:
-        name = 'test'
-    mocklayer = MockLayer()
-    checkbox = TSPCheckBox(mocklayer)
-    # should have text 'test'
-    assert checkbox.text() == 'test'
+    # test tabs
+    tabwidget = explorer.findChild(QtWidgets.QTabWidget)
+    assert tabwidget
+    assert all([tabwidget.tabText(ind) == gt for ind, gt in zip(range(2), ['Plotter', 'Options'])])
+    assert all([tabwidget.widget(ind) == gt for ind, gt in zip(range(2), [plotter, options])])
 
+    # test selector
+    selector = explorer.findChild(LayerSelector)
+    assert selector
 
-def test_tspcheckbox_rename():
-    class MockLayer:
-        name = 'test'
-    mocklayer = MockLayer()
-    checkbox = TSPCheckBox(mocklayer)
-    # rename layer and trigger rename of checkbox
-    mocklayer.name = 'testtest'
-    checkbox.rename(None)
-    assert checkbox.text() == 'testtest'
\ No newline at end of file
diff --git a/src/napari_time_series_plotter/_tests/test_utils.py b/src/napari_time_series_plotter/_tests/test_utils.py
new file mode 100644
index 0000000..af2fb68
--- /dev/null
+++ b/src/napari_time_series_plotter/_tests/test_utils.py
@@ -0,0 +1,71 @@
+from napari.layers import Image, Labels
+import numpy as np
+import pytest
+from qtpy.QtCore import Qt
+
+from ..utils import *
+
+
+# fixtures
+@pytest.fixture
+def layer_list():
+    return [
+        Image(data=np.random.randint(0, 100, (10, 10)), name='2D'),
+        Image(data=np.random.randint(0, 100, (10, 10, 10, 10)), name='4D'),
+        Labels(data=np.random.randint(0, 100, (10, 10, 10, 10)), name='L4D')
+    ]
+
+
+# tests
+def test_get_valid_image_layers(layer_list):
+    assert all(
+        [layer.ndim >= 3 and layer._type_string == 'image'
+         for layer in get_valid_image_layers(layer_list)]
+    )
+
+
+def test_extract_voxel_time_series(layer_list):
+    layer = layer_list[1]
+
+    # mock cursor position
+    cursor_pos_1 = np.array([3.2, 7.1, 2.9, 1.0])
+    cursor_pos_2 = np.array([3.1, -3.4, 5.0, 2.7])
+
+    # extracted data should be equal mock data
+    vts = extract_voxel_time_series(cursor_pos_1, layer)
+    assert np.all(vts == layer.data[:, 7, 3, 1])
+
+    # cursor position outside of the array index should yield no data
+    vts = extract_voxel_time_series(cursor_pos_2, layer)
+    assert not vts
+
+
+def test_SelectorListItem(layer_list):
+    layer = layer_list[1]
+    item = SelectorListItem(layer)
+
+    # test init
+    assert item.text() == layer.name
+    assert item.isCheckable()
+    assert not item.checkState()
+    assert item.layer == layer
+
+    # test type method
+    assert item.type() == 1001
+
+    # test _layer_name_changed callback
+    layer.name = 'Test'
+    assert item.text() == 'Test'
+
+
+def test_SelectorListModel(layer_list):
+    items = [SelectorListItem(layer) for layer in layer_list]
+    items[1].setCheckState(Qt.Checked)
+    model = SelectorListModel(items)
+
+    # test init
+    assert model.rowCount() == 3
+
+    # test get_checked
+    assert len(model.get_checked()) == 1
+    assert model.get_checked()[0].name == '4D'
diff --git a/src/napari_time_series_plotter/_tests/test_widgets.py b/src/napari_time_series_plotter/_tests/test_widgets.py
new file mode 100644
index 0000000..44bb674
--- /dev/null
+++ b/src/napari_time_series_plotter/_tests/test_widgets.py
@@ -0,0 +1,166 @@
+import numpy as np
+import pytest
+from pytestqt import qtbot
+from qtpy.QtCore import Signal, QObject
+
+from matplotlib.axes import Axes
+
+from ..widgets import LayerSelector, VoxelPlotter, OptionsManager
+from ..utils import SelectorListModel
+
+
+# fixtures
+@pytest.fixture
+def napari_viewer(make_napari_viewer):
+    viewer = make_napari_viewer(show=False)
+    viewer.add_image(np.random.randint(0, 100, (10, 10)), name='2D_image')
+    viewer.add_image(np.random.randint(0, 100, (10, 10, 10)), name='3D_image')
+    viewer.add_image(np.random.randint(0, 100, (10, 10, 10, 10)), name='4D_image')
+    viewer.add_labels(np.random.randint(0, 100, (10, 10, 10, 10)), name='4D_labels')
+    yield viewer
+
+
+@pytest.fixture
+def selector(napari_viewer):
+    yield LayerSelector(napari_viewer)
+
+
+@pytest.fixture
+def plotter(napari_viewer, selector: LayerSelector):
+    yield VoxelPlotter(napari_viewer, selector)
+
+
+# tests
+def test_LayerSelector(napari_viewer, selector: LayerSelector, qtbot: qtbot):
+    # test init
+    assert selector.napari_viewer == napari_viewer
+    assert isinstance(selector.model(), SelectorListModel)
+    assert not selector.parent()
+    assert selector.model().rowCount() == 2
+
+    # TODO: mouse click test
+
+    # test update_model
+    napari_viewer.add_image(np.random.randint(0, 100, (10, 10, 10, 10)), name='new')
+    with qtbot.waitSignal(selector.model().itemChanged, timeout=100):
+        selector.update_model(None)
+    assert selector.model().rowCount() == 3
+
+
+def test_VP_init(plotter: VoxelPlotter):
+    assert isinstance(plotter.selector, LayerSelector)
+    assert isinstance(plotter.axes, Axes)
+    assert not plotter.cursor_pos
+
+
+def test_VP_draw(plotter: VoxelPlotter):
+    # TODO: Add test for the actual annotate and plot
+    plotter = plotter
+    # test message plot
+    plotter.draw()
+    assert not plotter.axes.get_xmajorticklabels()
+    assert not plotter.axes.get_ymajorticklabels()
+
+    # test time series plot
+    plotter.axes.clear()
+    plotter.cursor_pos = np.array([0, 0, 0])
+    plotter.layers = [plotter.viewer.layers[1], plotter.viewer.layers[2]]
+    plotter.draw()
+    assert plotter.axes.get_title() == 'Position: [0 0 0]'
+    assert plotter.axes.get_xlabel() == 'Time'
+    assert plotter.axes.get_ylabel() == 'Pixel / Voxel Values'
+    handles = plotter.axes.get_legend_handles_labels()
+    assert len(handles) == 2
+    assert handles[1][0] == '3D_image'
+    assert handles[1][1] == '4D_image'
+
+    # test label truncate
+    plotter.axes.clear()
+    plotter.max_label_len = 2
+    plotter.draw()
+    handles = plotter.axes.get_legend_handles_labels()
+    assert len(handles) == 2
+    assert handles[1][0] == '3D'
+    assert handles[1][1] == '4D'
+
+    # test non auto scale
+    plotter.axes.clear()
+    x_lim = (0., 10.)
+    y_lim = (0., 10.)
+    plotter.x_lim = x_lim
+    plotter.y_lim = y_lim
+    plotter.autoscale = False
+    plotter.draw()
+    assert plotter.axes.get_xlim() == x_lim
+    assert plotter.axes.get_ylim() == y_lim
+
+
+def test_VP_shift_move_callback(plotter: VoxelPlotter, qtbot: qtbot):
+    # mock vent
+    class MockEvent:
+        def __init__(self):
+            self.modifiers = 'Shift'
+
+    # monkeypatch plotter._draw()
+    class MockSignal(QObject):
+        emitter = Signal()
+
+    signal = MockSignal()
+
+    def monkey_draw():
+        signal.emitter.emit()
+
+    plotter._draw = monkey_draw
+
+    event = MockEvent()
+    plotter.cursor_pos = (0, 0, 0)
+    plotter.layers = [plotter.viewer.layers[1]]
+    plotter.viewer.cursor.position = (10.1, 10.4, 9.8)
+
+    with qtbot.waitSignal(signal.emitter, timeout=100):
+        plotter._shift_move_callback(plotter.viewer, event)
+    assert np.all(plotter.cursor_pos == (10, 10, 10))
+
+
+def test_VP_update_options(plotter: VoxelPlotter):
+    options_dict = dict(
+        autoscale=False,
+        x_lim=(5, 15),
+        y_lim=(20, 25),
+        truncate=True,
+        trunc_len=2,
+    )
+    assert plotter.autoscale != options_dict['autoscale']
+    assert plotter.x_lim != options_dict['x_lim']
+    assert plotter.y_lim != options_dict['y_lim']
+    assert plotter.max_label_len != options_dict['trunc_len']
+
+    plotter.update_options(options_dict)
+    assert plotter.autoscale == options_dict['autoscale']
+    assert plotter.x_lim == options_dict['x_lim']
+    assert plotter.y_lim == options_dict['y_lim']
+    assert plotter.max_label_len == options_dict['trunc_len']
+
+
+def test_TSPOptions(qtbot: qtbot):
+    tspoptions = OptionsManager()
+
+    def check_return(status: dict):
+        return all([
+            status['autoscale'] is True,
+            status['x_lim'] == (None, None),
+            status['y_lim'] == (None, None),
+            status['truncate'] is False,
+            status['trunc_len'] is None,
+        ])
+
+    # test init
+    # TODO: run meaningful init tests
+
+    # test plotter_options
+    assert check_return(tspoptions.plotter_options())
+
+    # test poc_callback
+    # TODO: add qtbot mouse click and line edit tests
+    with qtbot.waitSignal(tspoptions.plotter_option_changed, timeout=100, check_params_cb=check_return):
+        tspoptions.poc_callback()