Repository: napari/napari

Issue:
Slider focus issues ?
## 🐛 Bug

I'm not too sure how to reproduce as I was smashing keys to test the action manager with `python examples/nD_volume.py`
but it seem like some combinaison of roll/transpose/3d<->2d and Alt-Up/Down can trigger an error:

```
Traceback (most recent call last):
  File "/Users/mbussonnier/dev/vispy/vispy/app/backends/_qt.py", line 520, in keyPressEvent
    self._keyEvent(self._vispy_canvas.events.key_press, ev)
  File "/Users/mbussonnier/dev/vispy/vispy/app/backends/_qt.py", line 569, in _keyEvent
    func(native=ev, key=key, text=str(ev.text()), modifiers=mod)
  File "/Users/mbussonnier/dev/vispy/vispy/util/event.py", line 454, in __call__
    self._invoke_callback(cb, event)
  File "/Users/mbussonnier/dev/vispy/vispy/util/event.py", line 472, in _invoke_callback
    _handle_exception(self.ignore_callback_errors,
  File "/Users/mbussonnier/dev/vispy/vispy/util/event.py", line 470, in _invoke_callback
    cb(event)
  File "/Users/mbussonnier/dev/napari/napari/utils/key_bindings.py", line 629, in on_key_press
    self.press_key(combo)
  File "/Users/mbussonnier/dev/napari/napari/utils/key_bindings.py", line 585, in press_key
    gen = func()
  File "/Users/mbussonnier/dev/napari/napari/components/_viewer_key_bindings.py", line 66, in focus_axes_up
  File "/Users/mbussonnier/dev/napari/napari/components/dims.py", line 293, in _focus_up
    index = (sliders.index(self.last_used) + 1) % len(sliders)
ValueError: X is not in list
```
Where X is 0, 3 .. and likely 1 or 2 as well, though I haven't observed those.




Discussion:
Ha! Ok probably a bug in there somewhere!! I'll see if i can reproduce
I can reproduce this in current main (commit: 9da6d289735e4c7d3abbcd111f03f5c61891d7ff)

use `python examples/nD_image.py`
Roll the axes twice using button or keyboard shortcut.
Then try alt-up or alt-down to change focus on sliders:
```
ValueError                                Traceback (most recent call last)
File ~/Dev/napari/napari/_qt/qt_viewer.py:855, in QtViewer.keyPressEvent(self=<napari._qt.qt_viewer.QtViewer object>, event=<PyQt5.QtGui.QKeyEvent object>)
    847 def keyPressEvent(self, event):
    848     """Called whenever a key is pressed.
    849 
    850     Parameters
   (...)
    853         Event from the Qt context.
    854     """
--> 855     self.canvas._scene_canvas._backend._keyEvent(
        self.canvas._scene_canvas._backend = <vispy.app.backends._qt.CanvasBackendDesktop object at 0x28a75f7f0>
        self.canvas._scene_canvas = <NapariSceneCanvas (PyQt5) at 0x28a766b60>
        self.canvas = <napari._vispy.canvas.VispyCanvas object at 0x28a7660b0>
        self = <napari._qt.qt_viewer.QtViewer object at 0x28a75c0d0>
        self.canvas._scene_canvas.events.key_press = <vispy.util.event.EventEmitter object at 0x28a76c790>
        event = <PyQt5.QtGui.QKeyEvent object at 0x291e379a0>
        self.canvas._scene_canvas.events = <vispy.util.event.EmitterGroup object at 0x28a76c5e0>
    856         self.canvas._scene_canvas.events.key_press, event
    857     )
    858     event.accept()

File ~/Dev/miniforge3/envs/napari-dev/lib/python3.10/site-packages/vispy/app/backends/_qt.py:622, in QtBaseCanvasBackend._keyEvent(self=<vispy.app.backends._qt.CanvasBackendDesktop object>, func=<vispy.util.event.EventEmitter object>, ev=<PyQt5.QtGui.QKeyEvent object>)
    620     key = None
    621 mod = self._modifiers(ev)
--> 622 func(native=ev, key=key, text=str(ev.text()), modifiers=mod)
        key = <Key 'Up'>
        mod = (<Key 'Alt'>,)
        ev = <PyQt5.QtGui.QKeyEvent object at 0x291e379a0>
        func = <vispy.util.event.EventEmitter object at 0x28a76c790>

File ~/Dev/miniforge3/envs/napari-dev/lib/python3.10/site-packages/vispy/util/event.py:453, in EventEmitter.__call__(self=<vispy.util.event.EventEmitter object>, *args=(), **kwargs={'key': <Key 'Up'>, 'modifiers': (<Key 'Alt'>,), 'native': <PyQt5.QtGui.QKeyEvent object>, 'text': ''})
    450 if self._emitting > 1:
    451     raise RuntimeError('EventEmitter loop detected!')
--> 453 self._invoke_callback(cb, event)
        event = <KeyEvent blocked=False handled=False key=<Key 'Up'> modifiers=(<Key 'Alt'>,) native=<PyQt5.QtGui.QKeyEvent object at 0x291e379a0> source=None sources=[] text= type=key_press>
        self = <vispy.util.event.EventEmitter object at 0x28a76c790>
        cb = <bound method KeymapHandler.on_key_press of <napari.utils.key_bindings.KeymapHandler object at 0x28a741e10>>
    454 if event.blocked:
    455     break

File ~/Dev/miniforge3/envs/napari-dev/lib/python3.10/site-packages/vispy/util/event.py:471, in EventEmitter._invoke_callback(self=<vispy.util.event.EventEmitter object>, cb=<bound method KeymapHandler.on_key_press of <napari.utils.key_bindings.KeymapHandler object>>, event=<KeyEvent blocked=False handled=False key=<Key '...9a0> source=None sources=[] text= type=key_press>)
    469     cb(event)
    470 except Exception:
--> 471     _handle_exception(self.ignore_callback_errors,
        self = <vispy.util.event.EventEmitter object at 0x28a76c790>
        cb = <bound method KeymapHandler.on_key_press of <napari.utils.key_bindings.KeymapHandler object at 0x28a741e10>>
        event = <KeyEvent blocked=False handled=False key=<Key 'Up'> modifiers=(<Key 'Alt'>,) native=<PyQt5.QtGui.QKeyEvent object at 0x291e379a0> source=None sources=[] text= type=key_press>
        (cb, event) = (<bound method KeymapHandler.on_key_press of <napari.utils.key_bindings.KeymapHandler object at 0x28a741e10>>, <KeyEvent blocked=False handled=False key=<Key 'Up'> modifiers=(<Key 'Alt'>,) native=<PyQt5.QtGui.QKeyEvent object at 0x291e379a0> source=None sources=[] text= type=key_press>)
    472                       self.print_callback_errors,
    473                       self, cb_event=(cb, event))

File ~/Dev/miniforge3/envs/napari-dev/lib/python3.10/site-packages/vispy/util/event.py:469, in EventEmitter._invoke_callback(self=<vispy.util.event.EventEmitter object>, cb=<bound method KeymapHandler.on_key_press of <napari.utils.key_bindings.KeymapHandler object>>, event=<KeyEvent blocked=False handled=False key=<Key '...9a0> source=None sources=[] text= type=key_press>)
    467 def _invoke_callback(self, cb, event):
    468     try:
--> 469         cb(event)
        cb = <bound method KeymapHandler.on_key_press of <napari.utils.key_bindings.KeymapHandler object at 0x28a741e10>>
        event = <KeyEvent blocked=False handled=False key=<Key 'Up'> modifiers=(<Key 'Alt'>,) native=<PyQt5.QtGui.QKeyEvent object at 0x291e379a0> source=None sources=[] text= type=key_press>
    470     except Exception:
    471         _handle_exception(self.ignore_callback_errors,
    472                           self.print_callback_errors,
    473                           self, cb_event=(cb, event))

File ~/Dev/napari/napari/utils/key_bindings.py:504, in KeymapHandler.on_key_press(self=<napari.utils.key_bindings.KeymapHandler object>, event=<KeyEvent blocked=False handled=False key=<Key '...9a0> source=None sources=[] text= type=key_press>)
    494 if (
    495     event.native is not None
    496     and event.native.isAutoRepeat()
   (...)
    500     # unless the combo being held down is one of the autorepeatables or
    501     # one of the navigation keys (helps with scrolling).
    502     return
--> 504 self.press_key(kb)
        kb = <app_model.types._keys._keybindings.KeyBinding object at 0x291e680d0>
        self = <napari.utils.key_bindings.KeymapHandler object at 0x28a741e10>

File ~/Dev/napari/napari/utils/key_bindings.py:426, in KeymapHandler.press_key(self=<napari.utils.key_bindings.KeymapHandler object>, key_bind=<app_model.types._keys._keybindings.KeyBinding object>)
    417 if not callable(func):
    418     raise TypeError(
    419         trans._(
    420             "expected {func} to be callable",
   (...)
    423         )
    424     )
--> 426 generator_or_callback = func()
        func = <bound method focus_axes_up of Viewer(camera=Camera(center=(0.0, 4.5, 63.5), zoom=5.86328125, angles=(0.0, 0.0, 90.0), perspective=0.0, mouse_pan=True, mouse_zoom=True), cursor=Cursor(position=(5.0, 64.0, 64.0, 64.0), scaled=True, size=1, style=<CursorStyle.STANDARD: 'standard'>), dims=Dims(ndim=4, ndisplay=2, order=(2, 3, 0, 1), axis_labels=('0', '1', '2', '3'), range=(RangeTuple(start=0.0, stop=10.0, step=1.0), RangeTuple(start=0.0, stop=128.0, step=1.0), RangeTuple(start=0.0, stop=128.0, step=1.0), RangeTuple(start=0.0, stop=128.0, step=1.0)), margin_left=(0.0, 0.0, 0.0, 0.0), margin_right=(0.0, 0.0, 0.0, 0.0), point=(5.0, 64.0, 64.0, 64.0), last_used=0), grid=GridCanvas(stride=1, shape=(-1, -1), enabled=False), layers=[<Image layer 'Image' at 0x16d5b0c40>], help='use <2> for transform', status='', tooltip=Tooltip(visible=False, text=''), theme='system', title='napari', mouse_over_canvas=False, mouse_move_callbacks=[], mouse_drag_callbacks=[], mouse_double_click_callbacks=[], mouse_wheel_callbacks=[<function dims_scroll at 0x14ca04dc0>], _persisted_mouse_event={}, _mouse_drag_gen={}, _mouse_wheel_gen={}, keymap={})>
    428 key = str(key_bind.parts[-1].key)
    430 if inspect.isgeneratorfunction(func):

File ~/Dev/miniforge3/envs/napari-dev/lib/python3.10/site-packages/in_n_out/_store.py:805, in Store.inject.<locals>._inner.<locals>._exec(*args=(Viewer(camera=Camera(center=(0.0, 4.5, 63.5), zo...ouse_drag_gen={}, _mouse_wheel_gen={}, keymap={}),), **kwargs={})
    798 logger.debug(
    799     "  Calling %s with %r (injected %r)",
    800     _fname,
    801     bound.arguments,
    802     _injected_names,
    803 )
    804 try:
--> 805     result = func(**bound.arguments)
        bound = <BoundArguments (viewer=Viewer(camera=Camera(center=(0.0, 4.5, 63.5), zoom=5.86328125, angles=(0.0, 0.0, 90.0), perspective=0.0, mouse_pan=True, mouse_zoom=True), cursor=Cursor(position=(5.0, 64.0, 64.0, 64.0), scaled=True, size=1, style=<CursorStyle.STANDARD: 'standard'>), dims=Dims(ndim=4, ndisplay=2, order=(2, 3, 0, 1), axis_labels=('0', '1', '2', '3'), range=(RangeTuple(start=0.0, stop=10.0, step=1.0), RangeTuple(start=0.0, stop=128.0, step=1.0), RangeTuple(start=0.0, stop=128.0, step=1.0), RangeTuple(start=0.0, stop=128.0, step=1.0)), margin_left=(0.0, 0.0, 0.0, 0.0), margin_right=(0.0, 0.0, 0.0, 0.0), point=(5.0, 64.0, 64.0, 64.0), last_used=0), grid=GridCanvas(stride=1, shape=(-1, -1), enabled=False), layers=[<Image layer 'Image' at 0x16d5b0c40>], help='use <2> for transform', status='', tooltip=Tooltip(visible=False, text=''), theme='system', title='napari', mouse_over_canvas=False, mouse_move_callbacks=[], mouse_drag_callbacks=[], mouse_double_click_callbacks=[], mouse_wheel_callbacks=[<function dims_scroll at 0x14ca04dc0>], _persisted_mouse_event={}, _mouse_drag_gen={}, _mouse_wheel_gen={}, keymap={}))>
        func = <function focus_axes_up at 0x16b4d9000>
        bound.arguments = {'viewer': Viewer(camera=Camera(center=(0.0, 4.5, 63.5), zoom=5.86328125, angles=(0.0, 0.0, 90.0), perspective=0.0, mouse_pan=True, mouse_zoom=True), cursor=Cursor(position=(5.0, 64.0, 64.0, 64.0), scaled=True, size=1, style=<CursorStyle.STANDARD: 'standard'>), dims=Dims(ndim=4, ndisplay=2, order=(2, 3, 0, 1), axis_labels=('0', '1', '2', '3'), range=(RangeTuple(start=0.0, stop=10.0, step=1.0), RangeTuple(start=0.0, stop=128.0, step=1.0), RangeTuple(start=0.0, stop=128.0, step=1.0), RangeTuple(start=0.0, stop=128.0, step=1.0)), margin_left=(0.0, 0.0, 0.0, 0.0), margin_right=(0.0, 0.0, 0.0, 0.0), point=(5.0, 64.0, 64.0, 64.0), last_used=0), grid=GridCanvas(stride=1, shape=(-1, -1), enabled=False), layers=[<Image layer 'Image' at 0x16d5b0c40>], help='use <2> for transform', status='', tooltip=Tooltip(visible=False, text=''), theme='system', title='napari', mouse_over_canvas=False, mouse_move_callbacks=[], mouse_drag_callbacks=[], mouse_double_click_callbacks=[], mouse_wheel_callbacks=[<function dims_scroll at 0x14ca04dc0>], _persisted_mouse_event={}, _mouse_drag_gen={}, _mouse_wheel_gen={}, keymap={})}
    806 except TypeError as e:
    807     if "missing" not in e.args[0]:

File ~/Dev/napari/napari/components/_viewer_key_bindings.py:96, in focus_axes_up(viewer=Viewer(camera=Camera(center=(0.0, 4.5, 63.5), zo...ouse_drag_gen={}, _mouse_wheel_gen={}, keymap={}))
     94 @register_viewer_action(trans._("Move focus of dimensions slider up."))
     95 def focus_axes_up(viewer: Viewer):
---> 96     viewer.dims._focus_up()
        viewer.dims = Dims(ndim=4, ndisplay=2, order=(2, 3, 0, 1), axis_labels=('0', '1', '2', '3'), range=(RangeTuple(start=0.0, stop=10.0, step=1.0), RangeTuple(start=0.0, stop=128.0, step=1.0), RangeTuple(start=0.0, stop=128.0, step=1.0), RangeTuple(start=0.0, stop=128.0, step=1.0)), margin_left=(0.0, 0.0, 0.0, 0.0), margin_right=(0.0, 0.0, 0.0, 0.0), point=(5.0, 64.0, 64.0, 64.0), last_used=0)
        viewer = Viewer(camera=Camera(center=(0.0, 4.5, 63.5), zoom=5.86328125, angles=(0.0, 0.0, 90.0), perspective=0.0, mouse_pan=True, mouse_zoom=True), cursor=Cursor(position=(5.0, 64.0, 64.0, 64.0), scaled=True, size=1, style=<CursorStyle.STANDARD: 'standard'>), dims=Dims(ndim=4, ndisplay=2, order=(2, 3, 0, 1), axis_labels=('0', '1', '2', '3'), range=(RangeTuple(start=0.0, stop=10.0, step=1.0), RangeTuple(start=0.0, stop=128.0, step=1.0), RangeTuple(start=0.0, stop=128.0, step=1.0), RangeTuple(start=0.0, stop=128.0, step=1.0)), margin_left=(0.0, 0.0, 0.0, 0.0), margin_right=(0.0, 0.0, 0.0, 0.0), point=(5.0, 64.0, 64.0, 64.0), last_used=0), grid=GridCanvas(stride=1, shape=(-1, -1), enabled=False), layers=[<Image layer 'Image' at 0x16d5b0c40>], help='use <2> for transform', status='', tooltip=Tooltip(visible=False, text=''), theme='system', title='napari', mouse_over_canvas=False, mouse_move_callbacks=[], mouse_drag_callbacks=[], mouse_double_click_callbacks=[], mouse_wheel_callbacks=[<function dims_scroll at 0x14ca04dc0>], _persisted_mouse_event={}, _mouse_drag_gen={}, _mouse_wheel_gen={}, keymap={})

File ~/Dev/napari/napari/components/dims.py:412, in Dims._focus_up(self=Dims(ndim=4, ndisplay=2, order=(2, 3, 0, 1), axi...0.0), point=(5.0, 64.0, 64.0, 64.0), last_used=0))
    409 if len(sliders) == 0:
    410     return
--> 412 index = (sliders.index(self.last_used) + 1) % len(sliders)
        sliders = [2, 3]
        self.last_used = 0
        len(sliders) = 2
        self = Dims(ndim=4, ndisplay=2, order=(2, 3, 0, 1), axis_labels=('0', '1', '2', '3'), range=(RangeTuple(start=0.0, stop=10.0, step=1.0), RangeTuple(start=0.0, stop=128.0, step=1.0), RangeTuple(start=0.0, stop=128.0, step=1.0), RangeTuple(start=0.0, stop=128.0, step=1.0)), margin_left=(0.0, 0.0, 0.0, 0.0), margin_right=(0.0, 0.0, 0.0, 0.0), point=(5.0, 64.0, 64.0, 64.0), last_used=0)
    413 self.last_used = sliders[index]

ValueError: 0 is not in list
```
If you roll again you will also get the error. If you roll another time, so back to the start, then everything is fine again.
Hi, checking this seems that what is needed is just to capture the exception and fallback the `index` variable value to a value that will be always available if the `sliders` list has elements, like for example setting `index` to 0. It could look something like:

```python
        try:
            index = (sliders.index(self.last_used) + 1) % len(sliders)
        except ValueError:
            # Fallback to a valid value for the slider index in case the
            # dimentions where rolled and the last used dimension is not valid
            # anymore. See napari/napari#2388
            index = 0
```

This handling will need to be added in the `_focus_up` and `_focus_down` methods at

https://github.com/napari/napari/blob/19f83a2195c55518f7b89146f704021017118679/napari/components/dims.py#L408-L424

With the change above I'm able to roll dims and change sliders without issues:


![roll](https://github.com/napari/napari/assets/16781833/da4e971d-ed92-48b7-ab60-cc840a538d4f)

That makes sense to me, but maybe Dims experts @andy-sweet and @brisvag can chime in?
Swallowing the exception is OK as a solution here, but I'd prefer to solve the underlying issue so that the exception doesn't get raised.

I think following example illustrates that underlying issue:

```python
from napari.components import Dims
dims = Dims(ndim=4, ndisplay=2)
dims.last_used = 1 # simulates putting focus from slider 0 to slider 1
dims.ndisplay = 3 
dims.last_used # last_used is still 1 which is invalid because dim 1 is displayed now, should be 0 instead
```

One solution to fix that issue is to add something like this to the root validator to keep the model consistent:

```python
        # If the last used slider is no longer visible, use the first.
        last_used = values['last_used']
        ndisplay = values['ndisplay']
        displayed = order[-ndisplay :]
        if last_used in displayed:
            updated['last_used'] = 0
```

Qt components also mutate `Dims.last_used` (in particular see [`QtDims._update_display`](https://github.com/napari/napari/blob/49e235ae94e4a3c9d473208a10fa191a22a07c76/napari/_qt/widgets/qt_dims.py#L108)), so we may need to think through the order of operations here (i.e. the order or mutations to `ndisplay`, `order`, and `last_used`) to ensure we don't break the focus behavior. But I think the fix above is at least as good as the exception handling in that respect.

Resolving patch:
diff --git a/napari/components/dims.py b/napari/components/dims.py
--- a/napari/components/dims.py
+++ b/napari/components/dims.py
@@ -218,16 +218,31 @@ def _check_dims(cls, values):
         elif labels_ndim > ndim:
             updated['axis_labels'] = axis_labels[-ndim:]
 
+        # If the last used slider is no longer visible, use the first.
+        last_used = values['last_used']
+        ndisplay = values['ndisplay']
+        dims_range = updated['range']
+        nsteps = cls._nsteps_from_range(dims_range)
+        not_displayed = [
+            d for d in order[:-ndisplay] if len(nsteps) > d and nsteps[d] > 1
+        ]
+        if len(not_displayed) > 0 and last_used not in not_displayed:
+            updated['last_used'] = not_displayed[0]
+
         return {**values, **updated}
 
-    @property
-    def nsteps(self) -> Tuple[float, ...]:
+    @staticmethod
+    def _nsteps_from_range(dims_range) -> Tuple[float, ...]:
         return tuple(
             # "or 1" ensures degenerate dimension works
             int((rng.stop - rng.start) / (rng.step or 1)) + 1
-            for rng in self.range
+            for rng in dims_range
         )
 
+    @property
+    def nsteps(self) -> Tuple[float, ...]:
+        return self._nsteps_from_range(self.range)
+
     @nsteps.setter
     def nsteps(self, value):
         self.range = tuple(


Test patch:
diff --git a/napari/_qt/widgets/_tests/test_qt_dims.py b/napari/_qt/widgets/_tests/test_qt_dims.py
--- a/napari/_qt/widgets/_tests/test_qt_dims.py
+++ b/napari/_qt/widgets/_tests/test_qt_dims.py
@@ -274,7 +274,16 @@ def test_slider_press_updates_last_used(qtbot):
 
     for i, widg in enumerate(view.slider_widgets):
         widg.slider.sliderPressed.emit()
-        assert view.dims.last_used == i
+        if i in [0, 1, 2]:
+            # only the first three dims should have visible sliders
+            assert widg.isVisibleTo(view)
+            assert view.dims.last_used == i
+        else:
+            # sliders should not be visible for the follwing dims and the
+            # last_used should fallback to the first available dim with a
+            # visible slider (dim 0)
+            assert not widg.isVisibleTo(view)
+            assert view.dims.last_used == 0
 
 
 @pytest.mark.skipif(
diff --git a/napari/components/_tests/test_dims.py b/napari/components/_tests/test_dims.py
--- a/napari/components/_tests/test_dims.py
+++ b/napari/components/_tests/test_dims.py
@@ -339,6 +339,16 @@ def test_changing_focus():
     assert dims.last_used == 2
 
 
+def test_changing_focus_changing_ndisplay():
+    dims = Dims(ndim=4, ndisplay=2)
+    # simulates putting focus from slider 0 to slider 1
+    dims.last_used = 1
+    assert dims.last_used == 1
+    dims.ndisplay = 3
+    # last_used should change from 1 to 0 since dim 1 is displayed now
+    assert dims.last_used == 0
+
+
 def test_floating_point_edge_case():
     # see #4889
     dims = Dims(ndim=2)
diff --git a/napari/components/_tests/test_viewer_mouse_bindings.py b/napari/components/_tests/test_viewer_mouse_bindings.py
--- a/napari/components/_tests/test_viewer_mouse_bindings.py
+++ b/napari/components/_tests/test_viewer_mouse_bindings.py
@@ -38,12 +38,12 @@ def inverted(self):
         (
             ["Control"],
             WheelEvent(False),
-            [[5, 5, 5], [5, 5, 4], [5, 5, 3], [5, 5, 0]],
+            [[5, 5, 5], [4, 5, 5], [3, 5, 5], [0, 5, 5]],
         ),
         (
             ["Control"],
             WheelEvent(True),
-            [[5, 5, 5], [5, 5, 6], [5, 5, 7], [5, 5, 9]],
+            [[5, 5, 5], [6, 5, 5], [7, 5, 5], [9, 5, 5]],
         ),
     ],
 )
@@ -52,7 +52,7 @@ def test_paint(mouse_event, modifiers, native, expected_dim):
     viewer = ViewerModel()
     data = np.random.random((10, 10, 10))
     viewer.add_image(data)
-    viewer.dims.last_used = 2
+    viewer.dims.last_used = 0
     viewer.dims.set_point(axis=0, value=5)
     viewer.dims.set_point(axis=1, value=5)
     viewer.dims.set_point(axis=2, value=5)