Repository: napari/napari

Issue:
Display total number of slices on right end of corresponding slider
## 🚀 Feature

From my colleague @VolkerH who was trying out napari on his (oddly-shaped due to IO issues) data: it would be nice if the max value of each slider was shown e.g. at the right end of that slider, so that you could at a glance tell the shape of your data.


Discussion:
@jni @VolkerH, now that we have a QScrollBar, which has a width proportional to the number of steps, do you still think this is as important? If so we can add it after adding the names to the axes in #14 
I still think it would be useful to see the number of frames / z-slices etc. in absolute numbers (rather than based on reltive slider width). But I also realize that there are probably more urgent things. 
I agree with @VolkerH — having actual numbers is helpful, especially once you get to >30 slices or so, when it becomes harder to estimate the actual total value. At least, the total size could appear on hover.
@tlambert03 just wanted to draw your attention to this. should be relatively easy to add after #726 goes in if we want, but let's wait till that merges before doing anything here

Resolving patch:
diff --git a/napari/_qt/qt_dims.py b/napari/_qt/qt_dims.py
--- a/napari/_qt/qt_dims.py
+++ b/napari/_qt/qt_dims.py
@@ -6,7 +6,6 @@
 from qtpy.QtWidgets import QLineEdit, QSizePolicy, QVBoxLayout, QWidget
 
 from ..components.dims import Dims
-from ..components.dims_constants import DimsMode
 from .qt_dims_slider import QtDimSliderWidget
 from ._constants import LoopMode
 
@@ -111,11 +110,7 @@ def _update_slider(self, axis: int):
         if axis >= len(self.slider_widgets):
             return
 
-        slider = self.slider_widgets[axis].slider
-
-        mode = self.dims.mode[axis]
-        if mode == DimsMode.POINT:
-            slider.setValue(self.dims.point[axis])
+        self.slider_widgets[axis]._update_slider()
         self.last_used = axis
 
     def _update_range(self, axis: int):
@@ -132,6 +127,7 @@ def _update_range(self, axis: int):
         self.slider_widgets[axis]._update_range()
         nsliders = np.sum(self._displayed_sliders)
         self.setMinimumHeight(nsliders * self.SLIDERHEIGHT)
+        self._resize_slice_labels()
 
     def _update_display(self, event=None):
         """Updates display for all sliders.
@@ -168,7 +164,7 @@ def _update_nsliders(self, event=None):
             if self._displayed_sliders[i]:
                 self._update_slider(i)
 
-    def _resize_labels(self):
+    def _resize_axis_labels(self):
         """When any of the labels get updated, this method updates all label
         widths to the width of the longest label. This keeps the sliders
         left-aligned and allows the full label to be visible at all times,
@@ -185,6 +181,24 @@ def _resize_labels(self):
         for labl in labels:
             labl.setFixedWidth(newwidth + 10)
 
+    def _resize_slice_labels(self):
+        """When the size of any dimension changes, we want to resize all of the
+        slice labels to width of the longest label, to keep all the sliders
+        right aligned.  The width is determined by the number of digits in the
+        largest dimensions, plus a little padding.
+        """
+        width = 0
+        for ax, maxi in enumerate(self.dims.max_indices):
+            if self._displayed_sliders[ax]:
+                length = len(str(int(maxi)))
+                if length > width:
+                    width = length
+        # gui width of a string of length `width`
+        fm = QFontMetrics(QFont("", 0))
+        width = fm.width("8" * width)
+        for labl in self.findChildren(QWidget, 'slice_label'):
+            labl.setFixedWidth(width + 6)
+
     def _create_sliders(self, number_of_sliders: int):
         """Creates sliders to match new number of dimensions.
 
@@ -198,14 +212,14 @@ def _create_sliders(self, number_of_sliders: int):
         for slider_num in range(self.nsliders, number_of_sliders):
             dim_axis = number_of_sliders - slider_num - 1
             slider_widget = QtDimSliderWidget(self, dim_axis)
-            slider_widget.label_changed.connect(self._resize_labels)
+            slider_widget.axis_label_changed.connect(self._resize_axis_labels)
             slider_widget.play_button.play_requested.connect(self.play)
             self.layout().addWidget(slider_widget)
             self.slider_widgets.insert(0, slider_widget)
             self._displayed_sliders.insert(0, True)
             nsliders = np.sum(self._displayed_sliders)
             self.setMinimumHeight(nsliders * self.SLIDERHEIGHT)
-        self._resize_labels()
+        self._resize_axis_labels()
 
     def _trim_sliders(self, number_of_sliders):
         """Trims number of dimensions to a lower number.
diff --git a/napari/_qt/qt_dims_slider.py b/napari/_qt/qt_dims_slider.py
--- a/napari/_qt/qt_dims_slider.py
+++ b/napari/_qt/qt_dims_slider.py
@@ -1,24 +1,27 @@
 from typing import Optional, Tuple
 
 import numpy as np
-from qtpy.QtCore import Qt, QTimer, Signal, Slot, QObject
-from qtpy.QtWidgets import QApplication
+from qtpy.QtCore import QObject, Qt, QTimer, Signal, Slot
+from qtpy.QtGui import QIntValidator
 from qtpy.QtWidgets import (
+    QApplication,
+    QCheckBox,
     QComboBox,
+    QDoubleSpinBox,
     QHBoxLayout,
     QLabel,
     QLineEdit,
     QPushButton,
-    QDoubleSpinBox,
     QWidget,
-    QCheckBox,
+    QFrame,
 )
 
+from ..components.dims_constants import DimsMode
 from ..util.event import Event
+from ._constants import LoopMode
 from .qt_modal import QtPopup
 from .qt_scrollbar import ModifiedScrollBar
 from .util import new_worker_qthread
-from ._constants import LoopMode
 
 
 class QtDimSliderWidget(QWidget):
@@ -28,7 +31,7 @@ class QtDimSliderWidget(QWidget):
     This widget *must* be instantiated with a parent QtDims.
     """
 
-    label_changed = Signal(int, str)  # axis, label
+    axis_label_changed = Signal(int, str)  # axis, label
     fps_changed = Signal(float)
     mode_changed = Signal(str)
     range_changed = Signal(tuple)
@@ -40,9 +43,37 @@ def __init__(self, parent: QWidget, axis: int):
         self.axis = axis
         self.qt_dims = parent
         self.dims = parent.dims
-        self.label = None
+        self.axis_label = None
         self.slider = None
         self.play_button = None
+        self.curslice_label = QLineEdit(self)
+        self.curslice_label.setToolTip(f'Current slice for axis {axis}')
+        # if we set the QIntValidator to actually reflect the range of the data
+        # then an invalid (i.e. too large) index doesn't actually trigger the
+        # editingFinished event (the user is expected to change the value)...
+        # which is confusing to the user, so instead we use an IntValidator
+        # that makes sure the user can only enter integers, but we do our own
+        # value validation in change_slice
+        self.curslice_label.setValidator(QIntValidator(0, 999999))
+
+        def change_slice():
+            val = int(self.curslice_label.text())
+            max_allowed = self.dims.max_indices[self.axis]
+            if val > max_allowed:
+                val = max_allowed
+                self.curslice_label.setText(str(val))
+            self.curslice_label.clearFocus()
+            self.qt_dims.setFocus()
+            self.dims.set_point(self.axis, val)
+
+        self.curslice_label.editingFinished.connect(change_slice)
+        self.totslice_label = QLabel(self)
+        self.totslice_label.setToolTip(f'Total slices for axis {axis}')
+        self.curslice_label.setObjectName('slice_label')
+        self.totslice_label.setObjectName('slice_label')
+        sep = QFrame(self)
+        sep.setFixedSize(1, 14)
+        sep.setObjectName('slice_label_sep')
 
         self._fps = 10
         self._minframe = None
@@ -53,49 +84,18 @@ def __init__(self, parent: QWidget, axis: int):
         self._create_axis_label_widget()
         self._create_range_slider_widget()
         self._create_play_button_widget()
-        layout.addWidget(self.label)
+
+        layout.addWidget(self.axis_label)
         layout.addWidget(self.play_button)
         layout.addWidget(self.slider, stretch=1)
+        layout.addWidget(self.curslice_label)
+        layout.addWidget(sep)
+        layout.addWidget(self.totslice_label)
         layout.setContentsMargins(0, 0, 0, 0)
-        layout.setSpacing(3)
+        layout.setSpacing(2)
         self.setLayout(layout)
         self.dims.events.axis_labels.connect(self._pull_label)
 
-    def _create_play_button_widget(self):
-        """Creates the actual play button, which has the modal popup."""
-        self.play_button = QtPlayButton(self.qt_dims, self.axis)
-        self.play_button.mode_combo.activated[str].connect(
-            lambda x: self.__class__.loop_mode.fset(
-                self, LoopMode(x.replace(' ', '_'))
-            )
-        )
-
-        def fps_listener(*args):
-            fps = self.play_button.fpsspin.value()
-            fps *= -1 if self.play_button.reverse_check.isChecked() else 1
-            self.__class__.fps.fset(self, fps)
-
-        self.play_button.fpsspin.editingFinished.connect(fps_listener)
-        self.play_button.reverse_check.stateChanged.connect(fps_listener)
-        self.play_stopped.connect(self.play_button._handle_stop)
-        self.play_started.connect(self.play_button._handle_start)
-
-    def _pull_label(self, event):
-        """Updates the label LineEdit from the dims model."""
-        if event.axis == self.axis:
-            label = self.dims.axis_labels[self.axis]
-            self.label.setText(label)
-            self.label_changed.emit(self.axis, label)
-
-    def _update_label(self):
-        with self.dims.events.axis_labels.blocker():
-            self.dims.set_axis_label(self.axis, self.label.text())
-        self.label_changed.emit(self.axis, self.label.text())
-
-    def _clear_label_focus(self):
-        self.label.clearFocus()
-        self.qt_dims.setFocus()
-
     def _create_axis_label_widget(self):
         """Create the axis label widget which accompanies its slider."""
         label = QLineEdit(self)
@@ -109,7 +109,7 @@ def _create_axis_label_widget(self):
         label.setContentsMargins(0, 0, 2, 0)
         label.textChanged.connect(self._update_label)
         label.editingFinished.connect(self._clear_label_focus)
-        self.label = label
+        self.axis_label = label
 
     def _create_range_slider_widget(self):
         """Creates a range slider widget for a given axis."""
@@ -140,6 +140,41 @@ def slider_focused_listener():
         slider.sliderPressed.connect(slider_focused_listener)
         self.slider = slider
 
+    def _create_play_button_widget(self):
+        """Creates the actual play button, which has the modal popup."""
+        self.play_button = QtPlayButton(self.qt_dims, self.axis)
+        self.play_button.mode_combo.activated[str].connect(
+            lambda x: self.__class__.loop_mode.fset(
+                self, LoopMode(x.replace(' ', '_'))
+            )
+        )
+
+        def fps_listener(*args):
+            fps = self.play_button.fpsspin.value()
+            fps *= -1 if self.play_button.reverse_check.isChecked() else 1
+            self.__class__.fps.fset(self, fps)
+
+        self.play_button.fpsspin.editingFinished.connect(fps_listener)
+        self.play_button.reverse_check.stateChanged.connect(fps_listener)
+        self.play_stopped.connect(self.play_button._handle_stop)
+        self.play_started.connect(self.play_button._handle_start)
+
+    def _pull_label(self, event):
+        """Updates the label LineEdit from the dims model."""
+        if event.axis == self.axis:
+            label = self.dims.axis_labels[self.axis]
+            self.axis_label.setText(label)
+            self.axis_label_changed.emit(self.axis, label)
+
+    def _update_label(self):
+        with self.dims.events.axis_labels.blocker():
+            self.dims.set_axis_label(self.axis, self.axis_label.text())
+        self.axis_label_changed.emit(self.axis, self.axis_label.text())
+
+    def _clear_label_focus(self):
+        self.axis_label.clearFocus()
+        self.qt_dims.setFocus()
+
     def _update_range(self):
         """Updates range for slider."""
         displayed_sliders = self.qt_dims._displayed_sliders
@@ -163,10 +198,27 @@ def _update_range(self):
                 self.slider.setMaximum(_range[1])
                 self.slider.setSingleStep(_range[2])
                 self.slider.setPageStep(_range[2])
+                maxi = self.dims.max_indices[self.axis]
+                self.totslice_label.setText(str(int(maxi)))
+                self.totslice_label.setAlignment(Qt.AlignLeft)
+                self._update_slice_labels()
         else:
             displayed_sliders[self.axis] = False
             self.slider.hide()
 
+    def _update_slider(self):
+        mode = self.dims.mode[self.axis]
+        if mode == DimsMode.POINT:
+            self.slider.setValue(self.dims.point[self.axis])
+            self._update_slice_labels()
+
+    def _update_slice_labels(self):
+        step = self.dims.range[self.axis][2]
+        self.curslice_label.setText(
+            str(int(self.dims.point[self.axis] // step))
+        )
+        self.curslice_label.setAlignment(Qt.AlignRight)
+
     @property
     def fps(self):
         return self._fps
diff --git a/napari/components/dims.py b/napari/components/dims.py
--- a/napari/components/dims.py
+++ b/napari/components/dims.py
@@ -121,6 +121,12 @@ def range(self):
         """
         return copy(self._range)
 
+    @property
+    def max_indices(self):
+        """Maximum index for each dimension (in data space).
+        """
+        return [((ma - st) // st) for mi, ma, st in self._range]
+
     @property
     def point(self):
         """List of int: value of each dimension if in POINT mode."""


Test patch:
diff --git a/napari/_qt/tests/test_qt_dims.py b/napari/_qt/tests/test_qt_dims.py
--- a/napari/_qt/tests/test_qt_dims.py
+++ b/napari/_qt/tests/test_qt_dims.py
@@ -3,6 +3,8 @@
 from napari.components import Dims
 from napari._qt.qt_dims import QtDims
 from qtpy.QtCore import Qt
+from napari._qt.qt_viewer import QtViewer
+from napari.components import ViewerModel
 
 
 def test_creating_view(qtbot):
@@ -185,9 +187,9 @@ def test_update_dims_labels(qtbot):
     view = QtDims(Dims(ndim))
     qtbot.addWidget(view)
     view.dims.axis_labels = list('TZYX')
-    assert [w.label.text() for w in view.slider_widgets] == list('TZYX')
+    assert [w.axis_label.text() for w in view.slider_widgets] == list('TZYX')
 
-    first_label = view.slider_widgets[0].label
+    first_label = view.slider_widgets[0].axis_label
     assert first_label.text() == view.dims.axis_labels[0]
     first_label.setText('napari')
     # first_label.editingFinished.emit()
@@ -223,3 +225,25 @@ def test_play_button(qtbot):
     assert not button.popup.isVisible()
     qtbot.mouseClick(button, Qt.RightButton)
     assert button.popup.isVisible()
+
+
+def test_slice_labels(qtbot):
+    viewer = ViewerModel()
+    view = QtViewer(viewer)
+    qtbot.addWidget(view)
+    np.random.seed(0)
+    data = np.random.random((20, 10, 10))
+    viewer.add_image(data)
+
+    # make sure the totslice_label is showing the correct number
+    assert int(view.dims.slider_widgets[0].totslice_label.text()) == 19
+
+    # make sure setting the dims.point updates the slice label
+    label_edit = view.dims.slider_widgets[0].curslice_label
+    viewer.dims.set_point(0, 15)
+    assert int(label_edit.text()) == 15
+
+    # make sure setting the current slice label updates the model
+    label_edit.setText(str(8))
+    label_edit.editingFinished.emit()
+    assert viewer.dims.point[0] == 8