Repository: napari/napari

Issue:
Incorrect number of sliders for particular shapes
## 🐛 Bug

This might be due to trying to be too clever with detecting multichannel, but maybe not... If I try to visualise an array of shape (51, 5, 2, 196, 171), I only get two sliders, corresponding to dimensions 0 and 2. I have no way to scroll through dimension 1. Additionally, there are too many stops for dimension 2, which can be set to three positions, 0, 1, and 2. The status happily tells me that I'm looking at coordinates [0, 0, 2, <whatever>, <whatever>]. 

Note: this was discovered while trying to visualise `mitosis.tif` from the Fiji examples, but random data does the trick (see below)

## To Reproduce

In IPython:

```python
import numpy as np
import napari

image = np.random.random((51, 5, 2, 196, 171))
napari.view(image)
```

As a script:

```python
import numpy as np
import napari
import napari.util

image = np.random.random((51, 5, 2, 196, 171))
with napari.util.app_context():
    napari.view(image)
```

## Expected behavior

I get three sliders, and they range from 0-50, 0-4, and 0-1.

## Environment

 - napari Version (e.g., 1.0): master
 - OS (e.g., Linux): Ubuntu 18.04
 - Python version: 3.6
 - Any other relevant information:

## Additional context

<!-- Add any other context about the problem here. -->



Discussion:
The too many stops bug is true for all of our sliders and noted in #287, but has not been fixed yet. I'm surprised that you're missing a slider though, and I'll look into it 

Resolving patch:
diff --git a/examples/5D_image.py b/examples/5D_image.py
new file mode 100644
--- /dev/null
+++ b/examples/5D_image.py
@@ -0,0 +1,13 @@
+"""
+Display one 5-D image layer using the add_image API
+"""
+
+import numpy as np
+from skimage import data
+import napari
+from napari.util import app_context
+
+
+with app_context():
+
+    viewer = napari.view(np.random.random((10, 20, 15, 30, 40)))
diff --git a/napari/components/_dims/model.py b/napari/components/_dims/model.py
--- a/napari/components/_dims/model.py
+++ b/napari/components/_dims/model.py
@@ -22,9 +22,11 @@ class Dims:
     range : list of 3-tuple
         List of tuples (min, max, step), one for each dimension
     point : list of float
-        List of floats, one for each dimension
+        List of floats setting the current value of the range slider when in
+        POINT mode, one for each dimension
     interval : list of 2-tuple
-        List of tuples (min, max), one for each dimension
+        List of tuples (min, max) setting the current selection of the range
+        slider when in INTERVAL mode, one for each dimension
     mode : list of DimsMode
         List of DimsMode, one for each dimension
     display : list of bool
@@ -32,8 +34,6 @@ class Dims:
         dimension
     ndim : int
         Number of dimensions
-    displayed : list of int
-        Array of the displayed dimensions
     indices : tuple of slice object
         Tuple of slice objects for slicing arrays on each dimension, one for
         each dimension
@@ -71,8 +71,8 @@ def __str__(self):
 
     @property
     def range(self):
-        """list of 3-tuple: List of tuples (min, max, step), one for each
-        dimension
+        """list of 3-tuple (min, max, step): total range and step size of each
+        dimension.
         """
         return copy(self._range)
 
@@ -87,13 +87,14 @@ def range(self, value):
 
     @property
     def point(self):
-        """list of float: List of floats, one for each dimension
+        """list of int: value of each dimension if in POINT mode.
         """
         return copy(self._point)
 
     @property
     def interval(self):
-        """list of 2-tuple: List of tuples (min, max), one for each dimension
+        """list of 2-tuple (min, max): Selection range of each dimension if in
+        INTERVAL mode.
         """
         return copy(self._interval)
 
@@ -125,9 +126,15 @@ def ndim(self):
     def ndim(self, ndim):
         if ndim > self.ndim:
             for i in range(self.ndim, ndim):
-                self._range.insert(0, (0.0, 1.0, 0.01))
-                self._point.insert(0, 0.0)
-                self._interval.insert(0, (0.3, 0.7))
+                # Insert default values for the new axis at the beginning of
+                # the lists
+                # Range value is (min, max, step) for the entire slider
+                self._range.insert(0, (0, 2, 1))
+                # Point is the slider value if in point mode
+                self._point.insert(0, 0)
+                # Interval value is the (min, max) of the slider selction
+                # if in interval mode
+                self._interval.insert(0, (0, 1))
                 self._mode.insert(0, DimsMode.POINT)
                 self._display.insert(0, False)
 
@@ -148,50 +155,25 @@ def ndim(self, ndim):
             # Notify listeners that the number of dimensions have changed
             self.events.ndim()
 
-    @property
-    def displayed(self):
-        """Returns the displayed dimensions
-
-        Returns
-        -------
-        displayed : list
-            Displayed dimensions
-        """
-        displayed = [i for i, d in enumerate(self.display) if d is True]
-        return displayed
-
     @property
     def indices(self):
         """Tuple of slice objects for slicing arrays on each dimension."""
         slice_list = []
-        z = zip(self.mode, self.display, self.point, self.interval, self.range)
-        for (mode, display, point, interval, range) in z:
-            if mode == DimsMode.POINT or mode is None:
+        z = zip(self.mode, self.display, self.point, self.interval)
+        for (mode, display, point, interval) in z:
+            if mode == DimsMode.POINT:
                 if display:
                     slice_list.append(slice(None, None, None))
                 else:
                     slice_list.append(int(round(point)))
             elif mode == DimsMode.INTERVAL:
                 if display:
-                    if interval is None:
-                        slice_list.append(slice(None))
-                    else:
-                        slice_list.append(
-                            slice(
-                                int(round(interval[0])),
-                                int(round(interval[1])),
-                            )
-                        )
+                    slice_list.append(slice(None, None, None))
+
                 else:
-                    if interval is None:
-                        slice_list.append(slice(None))
-                    else:
-                        slice_list.append(
-                            slice(
-                                int(round(interval[0])),
-                                int(round(interval[1])),
-                            )
-                        )
+                    slice_list.append(
+                        slice(int(round(interval[0])), int(round(interval[1])))
+                    )
 
         return tuple(slice_list)
 
diff --git a/napari/components/_dims/view.py b/napari/components/_dims/view.py
--- a/napari/components/_dims/view.py
+++ b/napari/components/_dims/view.py
@@ -47,21 +47,8 @@ def __init__(self, dims: Dims, parent=None):
         self.setLayout(layout)
         self.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Fixed)
 
-        # First we need to make sure that the current state of the model is
-        # correctly reflected in the view. This is important because in the
-        # general case, we might not have model and view synced if changes have
-        # occured before the view is initialised with the model.
-
-        # First we set the dimenions of the view with respect to the model:
-        self._set_nsliders(dims.ndim - 2)
-
-        # Then we set the mode for each slider:
-        for axis in range(0, dims.ndim - 2):
-            slider = self.sliders[axis]
-            if self.dims.mode[axis] == DimsMode.POINT:
-                slider.collapse()
-            else:
-                slider.expand()
+        # Update the number of sliders now that the dims have been added
+        self._update_nsliders()
 
         # The next lines connect events coming from the model to the Qt event
         # system: We need to go through Qt signals so that these events are run
@@ -112,21 +99,20 @@ def _update_slider(self, slider_index: int):
 
         slider = self.sliders[slider_index]
 
-        if slider is None:
-            return
-
-        if slider_index < self.dims.ndim:
-
-            mode = self.dims.mode[slider_index]
-            if mode == DimsMode.POINT:
-                slider.collapse()
-                slider.setValue(self.dims.point[slider_index])
-            elif mode == DimsMode.INTERVAL:
-                slider.expand()
-                slider.setValues(self.dims.interval[slider_index])
-            slider_range = self.dims.range[slider_index]
-            if slider_range not in (None, (None, None, None)):
-                slider.setRange(slider_range)
+        mode = self.dims.mode[slider_index]
+        if mode == DimsMode.POINT:
+            slider.collapse()
+            slider.setValue(self.dims.point[slider_index])
+        elif mode == DimsMode.INTERVAL:
+            slider.expand()
+            slider.setValues(self.dims.interval[slider_index])
+        # Set the maximum values of the range slider to be one step less than
+        # the range of the layer as otherwise the slider can move beyond the
+        # shape of the layer as the endpoint is included
+        range = self.dims.range[slider_index]
+        range = (range[0], range[1] - range[2], range[2])
+        if range not in (None, (None, None, None)):
+            slider.setRange(range)
 
     def _update_nsliders(self):
         """
@@ -155,13 +141,13 @@ def _create_sliders(self, number_of_sliders):
         ----------
         number_of_sliders : new number of sliders
         """
+        # add extra sliders so that number_of_sliders are present
+        # add to the beginning of the list
         for slider_num in range(self.nsliders, number_of_sliders):
-            slider = self._create_range_slider_widget(slider_num)
-            for i in range(self.nsliders):
-                item = self.layout().takeAt(i)
-                self.layout().addWidget(item.widget(), i + 1, 0)
-            self.layout().addWidget(slider, 0, 0)
-            self.sliders.append(slider)
+            dim_axis = number_of_sliders - slider_num - 1
+            slider = self._create_range_slider_widget(dim_axis)
+            self.layout().addWidget(slider)
+            self.sliders.insert(0, slider)
             self.setMinimumHeight(self.nsliders * self.SLIDERHEIGHT)
 
     def _trim_sliders(self, number_of_sliders):
@@ -173,8 +159,10 @@ def _trim_sliders(self, number_of_sliders):
         number_of_sliders : new number of sliders
         """
         # remove extra sliders so that only number_of_sliders are left
+        # remove from the beginning of the list
         for slider_num in range(number_of_sliders, self.nsliders):
-            slider = self.sliders.pop()
+            slider = self.sliders[0]
+            self.sliders = self.sliders[1:]
             self.layout().removeWidget(slider)
             slider.deleteLater()
         self.setMinimumHeight(self.nsliders * self.SLIDERHEIGHT)
@@ -192,6 +180,10 @@ def _create_range_slider_widget(self, axis):
         slider : range slider
         """
         range = self.dims.range[axis]
+        # Set the maximum values of the range slider to be one step less than
+        # the range of the layer as otherwise the slider can move beyond the
+        # shape of the layer as the endpoint is included
+        range = (range[0], range[1] - range[2], range[2])
         point = self.dims.point[axis]
 
         slider = QHRangeSlider(


Test patch:
diff --git a/napari/components/_dims/tests/dims_tests.py b/napari/components/_dims/tests/dims_tests.py
deleted file mode 100644
--- a/napari/components/_dims/tests/dims_tests.py
+++ /dev/null
@@ -1,48 +0,0 @@
-from napari.components import Dims
-from napari.components._dims._constants import DimsMode
-
-
-def test_point():
-    """
-    Tests point setting
-    """
-    dims = Dims(4)
-    dims.set_point(3, 2.5)
-
-    assert dims.ndim == 4
-    assert dims.point[0] == 0.0
-    assert dims.point[1] == 0.0
-    assert dims.point[2] == 0.0
-    assert dims.point[3] == 2.5
-
-
-def test_display():
-    """
-    Tests display setting
-    """
-    dims = Dims(4)
-    dims.set_display(0, False)
-    dims.set_display(1, False)
-    dims.set_display(2, True)
-    dims.set_display(3, True)
-
-    assert dims.ndim == 4
-    assert not dims.display[0]
-    assert not dims.display[1]
-    assert dims.display[2]
-    assert dims.display[3]
-    assert (dims.displayed == [2, 3]).all()
-
-
-def test_add_remove_dims():
-    """
-    Tests adding and removing dimensions
-    """
-    dims = Dims(2)
-    assert dims.ndim == 2
-
-    dims.ndim = 10
-    assert dims.ndim == 10
-
-    dims.ndim = 5
-    assert dims.ndim == 5
diff --git a/napari/components/_dims/tests/test_dims.py b/napari/components/_dims/tests/test_dims.py
new file mode 100644
--- /dev/null
+++ b/napari/components/_dims/tests/test_dims.py
@@ -0,0 +1,119 @@
+from napari.components import Dims
+from napari.components._dims._constants import DimsMode
+
+
+def test_ndim():
+    """
+    Test number of dimensions including after adding and removing dimensions.
+    """
+    dims = Dims()
+    assert dims.ndim == 0
+
+    dims = Dims(4)
+    assert dims.ndim == 4
+
+    dims = Dims(2)
+    assert dims.ndim == 2
+
+    dims.ndim = 10
+    assert dims.ndim == 10
+
+    dims.ndim = 5
+    assert dims.ndim == 5
+
+
+def test_display():
+    """
+    Test display setting.
+    """
+    dims = Dims(4)
+    assert dims.display == [False] * 4
+
+    dims.set_display(0, True)
+    dims.set_display(1, True)
+    assert dims.display == [True, True, False, False]
+
+    dims._set_2d_viewing()
+    assert dims.display == [False, False, True, True]
+
+
+def test_point():
+    """
+    Test point setting.
+    """
+    dims = Dims(4)
+    assert dims.point == [0] * 4
+
+    dims.set_point(3, 4)
+    assert dims.point == [0, 0, 0, 4]
+
+    dims.set_point(2, 1)
+    assert dims.point == [0, 0, 1, 4]
+
+
+def test_mode():
+    """
+    Test mode setting.
+    """
+    dims = Dims(4)
+    assert dims.mode == [DimsMode.POINT] * 4
+
+    dims.set_mode(3, DimsMode.INTERVAL)
+    assert dims.mode == [DimsMode.POINT] * 3 + [DimsMode.INTERVAL]
+
+
+def test_range():
+    """
+    Tests range setting.
+    """
+    dims = Dims(4)
+    assert dims.range == [(0, 2, 1)] * 4
+
+    dims.set_range(3, (0, 4, 2))
+    assert dims.range == [(0, 2, 1)] * 3 + [(0, 4, 2)]
+
+
+def test_interval():
+    """
+    Test interval setting.
+    """
+    dims = Dims(4)
+    assert dims.interval == [(0, 1)] * 4
+
+    dims.set_interval(3, (0, 3))
+    assert dims.interval == [(0, 1)] * 3 + [(0, 3)]
+
+
+def test_indices():
+    """
+    Test indices values.
+    """
+    dims = Dims(4)
+    # On instantiation no dims are displayed and the indices default to 0
+    assert dims.indices == (0,) * 4
+
+    dims._set_2d_viewing()
+    # On 2D viewing the last two dims are now set to sliced mode
+    assert dims.indices == (0,) * 2 + (slice(None, None, None),) * 2
+
+    dims.set_point(0, 2)
+    dims.set_point(1, 3)
+    # Set the values of the first two dims in point mode
+    assert dims.indices == (2, 3) + (slice(None, None, None),) * 2
+
+
+def test_order_when_changing_ndim():
+    """
+    Test order of the dims when changing the number of dimensions.
+    """
+    dims = Dims(4)
+    dims.set_point(0, 2)
+
+    dims.ndim = 5
+    # Test that new dims get appended to the beginning of lists
+    assert dims.point == [0, 2, 0, 0, 0]
+
+    dims.set_point(2, 3)
+    dims.ndim = 3
+    # Test that dims get removed from the beginning of lists
+    assert dims.point == [3, 0, 0]
diff --git a/napari/components/_dims/tests/test_dims_view.py b/napari/components/_dims/tests/test_dims_view.py
new file mode 100644
--- /dev/null
+++ b/napari/components/_dims/tests/test_dims_view.py
@@ -0,0 +1,125 @@
+import os
+from napari.components import Dims
+from napari.components._dims._constants import DimsMode
+from napari.components._dims.view import QtDims
+from napari.util import app_context
+
+os.environ['NAPARI_TEST'] = '1'
+
+
+def test_creating_view():
+    """
+    Test creating dims view.
+    """
+    with app_context():
+        ndim = 4
+        dims = Dims(ndim)
+        view = QtDims(dims)
+
+        # Check that the dims model has been appended to the dims view
+        assert view.dims == dims
+
+        # Check the number of sliders is two less than the number of dimensions
+        assert view.nsliders == ndim - 2
+
+
+def test_changing_ndim():
+    """
+    Test changing the number of dimensions
+    """
+    with app_context():
+        ndim = 4
+        view = QtDims(Dims(ndim))
+
+        # Check that adding dimensions adds sliders
+        view.dims.ndim = 5
+        assert view.nsliders == 3
+
+        # Check that removing dimensions removes sliders
+        view.dims.ndim = 2
+        assert view.nsliders == 0
+
+
+def test_slider_values():
+    """
+    Test the values of a slider stays matched to the values of the dims point.
+    """
+    with app_context():
+        ndim = 4
+        view = QtDims(Dims(ndim))
+
+        # Check that values of the dimension slider matches the values of the
+        # dims point at initialization
+        assert view.sliders[0].getValues() == [view.dims.point[0]] * 2
+
+        # Check that values of the dimension slider matches the values of the
+        # dims point after the point has been moved within the dims
+        view.dims.set_point(0, 2)
+        assert view.sliders[0].getValues() == [view.dims.point[0]] * 2
+
+        # Check that values of the dimension slider matches the values of the
+        # dims point after the point has been moved within the slider
+        view.sliders[0].setValue(1)
+        assert view.sliders[0].getValues() == [view.dims.point[0]] * 2
+
+
+def test_slider_range():
+    """
+    Tests range of the slider is matched to the range of the dims
+    """
+    with app_context():
+        ndim = 4
+        view = QtDims(Dims(ndim))
+
+        # Check the range of slider matches the values of the range of the dims
+        # at initialization
+        assert view.sliders[0].start == view.dims.range[0][0]
+        assert (
+            view.sliders[0].end
+            == view.dims.range[0][1] - view.dims.range[0][2]
+        )
+        assert view.sliders[0].single_step == view.dims.range[0][2]
+
+        # Check the range of slider stays matched to the values of the range of
+        # the dims
+        view.dims.set_range(0, (1, 5, 2))
+        assert view.sliders[0].start == view.dims.range[0][0]
+        assert (
+            view.sliders[0].end
+            == view.dims.range[0][1] - view.dims.range[0][2]
+        )
+        assert view.sliders[0].single_step == view.dims.range[0][2]
+
+
+def test_order_when_changing_ndim():
+    """
+    Test order of the sliders when changing the number of dimensions.
+    """
+    with app_context():
+        ndim = 4
+        view = QtDims(Dims(ndim))
+
+        # Check that values of the dimension slider matches the values of the
+        # dims point after the point has been moved within the dims
+        view.dims.set_point(0, 2)
+        view.dims.set_point(1, 1)
+        for i in range(view.dims.ndim - 2):
+            assert view.sliders[i].getValues() == [view.dims.point[i]] * 2
+
+        # Check the matching dimensions and sliders are preserved when
+        # dimensions are added
+        view.dims.ndim = 5
+        for i in range(view.dims.ndim - 2):
+            assert view.sliders[i].getValues() == [view.dims.point[i]] * 2
+
+        # Check the matching dimensions and sliders are preserved when dims
+        # dimensions are removed
+        view.dims.ndim = 4
+        for i in range(view.dims.ndim - 2):
+            assert view.sliders[i].getValues() == [view.dims.point[i]] * 2
+
+        # Check the matching dimensions and sliders are preserved when dims
+        # dimensions are removed
+        view.dims.ndim = 3
+        for i in range(view.dims.ndim - 2):
+            assert view.sliders[i].getValues() == [view.dims.point[i]] * 2